diff --git a/CHANGELOG.md b/CHANGELOG.md index 4340eae6..a7339ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,43 @@ Earlier releases (v0.1.x) are documented in the ## [Unreleased] +### Fixed — `tinkerdown cli` reaches WASM sources; wasm docs use the real config keys + +`tinkerdown cli` now constructs `wasm` sources, so a WASM source has the same CRUD +surface on the CLI as under `serve`: `list` works for any module, and +`add`/`update`/`delete` work when the module exports `write` (a read-only module +refuses a write with a clear error rather than dropping it). `exec` remains +intentionally out of the CLI CRUD surface (it is a read-oriented, `--allow-exec`-gated +command runner), now documented in the code rather than a silent fallthrough. The +WASM source docs and the `wasm-source` template were also corrected: they used a +non-existent `module:` key (the real key is `path:`) and `config:` (real: `options:`), +so following them failed to load — plus a new *Read-only vs. writable* section +documents the `write`-export contract and the CLI parity. + +### Added — a saved-skills gallery (`examples/gallery/`) + +A discoverable home for captured workflows: the gallery is itself a plain +Tinkerdown app (an `lvt-source` over a read-only CSV, no custom JavaScript) that +lists each captured skill with what it stands up and where its stand-up steps +live. Because no source type reads a directory of `SKILL.md` frontmatter, the +gallery renders a committed static index (`skills.csv`) rather than scanning +`skills/` live; a test (`TestSavedSkillsGalleryInSync`) keeps that index in step +with the captured skills on disk — every `skills/` dir except the +framework-authoring skills — so a new capture cannot silently go unlisted. + +### Changed — a saved skill now carries its house style + +`/tinkerdown:save` captures the manifest **whole**, not trimmed to sources + +actions: the `styling` block (theme + design `tokens`) and any +`generation.style_guide` → `style-guide.md` travel with the capture, under the +same bundle-or-point rule as `seed.sql` / `app.md`. So a saved workflow re-runs +**on-brand** instead of falling back to bare defaults — closing the gap where a +capture kept a console's data surface but dropped its palette. The PII reference +skill demonstrates the point case: it points at the committed +`examples/pii-access-approval/`, whose manifest carries the house style, and a +test now guards that the style file travels and the manifest still round-trips +its `styling.tokens` + `generation.style_guide`. + ### Changed — the generation skill now consumes the house style The `/tinkerdown` generation skill reads a project's house style and authors diff --git a/captured_skills_test.go b/captured_skills_test.go index c456d680..3901cad1 100644 --- a/captured_skills_test.go +++ b/captured_skills_test.go @@ -2,22 +2,39 @@ package tinkerdown_test import ( "os" + "path/filepath" "strings" "testing" + + "github.com/livetemplate/tinkerdown/internal/config" ) -// TestCapturedSkillsWellFormed checks the M1 Phase 6 skills — the captured -// pii-access-approval workflow and the /tinkerdown:save capability — are -// well-formed: valid frontmatter, and every repo path they point at exists. +// TestCapturedSkillsWellFormed checks the captured skills — the pii-access-approval +// workflow (M1 Phase 6) and the /tinkerdown:save capability — are well-formed: valid +// frontmatter, every repo path they point at exists, and (for a skill that points at a +// committed manifest) that manifest carries the house style the app ran on, so the +// saved workflow re-runs on-brand (M5 Phase 1 — capture completeness). // // The pii-access-approval skill deliberately POINTS at examples/pii-access-approval/ -// rather than copying it (the artifacts are a committed source of truth; a copy -// would be drift waiting to happen). This test is what makes that pointer safe: if -// the example is moved or renamed, the skill's dead reference fails here. +// rather than copying it (the artifacts are a committed source of truth; a copy would +// be drift waiting to happen). This test is what makes that pointer safe: if the +// example is moved, renamed, or loses its style file, the skill's dead reference fails +// here. +// +// The manifestDir round-trip below is real-state (config.LoadFromDir, not a SKILL.md +// substring). It overlaps TestPIIManifestHouseStyleConsumable by design — it asserts +// the same fact (the demo manifest exposes styling.tokens + generation.style_guide) but +// from the *capture* angle: a saved skill re-runs on-brand only if the manifest it +// points at still carries the style. The fixture path is a hardcoded constant, matching +// the refs below; it is NOT scraped out of the SKILL.md prose (that would be a +// self-certifying guard — a string re-asserting a string read from the same prose). func TestCapturedSkillsWellFormed(t *testing.T) { skills := []struct { path string refs []string // repo-relative paths the skill references, which must exist + // manifestDir, if set, is the committed manifest the skill points at; its + // house style (styling.tokens + generation.style_guide) must round-trip. + manifestDir string }{ { path: "skills/pii-access-approval/SKILL.md", @@ -25,8 +42,10 @@ func TestCapturedSkillsWellFormed(t *testing.T) { "examples/pii-access-approval/tinkerdown.yaml", "examples/pii-access-approval/app.md", "examples/pii-access-approval/seed.sql", + "examples/pii-access-approval/style-guide.md", "examples/pii-access-approval/README.md", }, + manifestDir: "examples/pii-access-approval", }, { path: "skills/tinkerdown-save/SKILL.md", @@ -57,5 +76,47 @@ func TestCapturedSkillsWellFormed(t *testing.T) { t.Errorf("%s references %q, which does not exist: %v", s.path, ref, err) } } + + if s.manifestDir == "" { + continue + } + // Capture completeness: the manifest the saved skill runs against must still + // carry the house style, or the workflow re-runs off-brand on defaults. + cfg, err := config.LoadFromDir(s.manifestDir) + if err != nil { + t.Errorf("%s: load manifest %q: %v", s.path, s.manifestDir, err) + continue + } + if len(cfg.Styling.Tokens) == 0 { + t.Errorf("%s: manifest %q carries no styling.tokens — saved workflow would re-run off-brand", + s.path, s.manifestDir) + } + if cfg.Generation == nil || cfg.Generation.StyleGuide == "" { + t.Errorf("%s: manifest %q has no generation.style_guide — house-style prose does not travel", + s.path, s.manifestDir) + continue + } + guidePath := filepath.Join(s.manifestDir, cfg.Generation.StyleGuide) + if _, err := os.Stat(guidePath); err != nil { + t.Errorf("%s: generation.style_guide %q does not resolve to a file (%s): %v", + s.path, cfg.Generation.StyleGuide, guidePath, err) + } + } +} + +// TestSaveSkillCapturesHouseStyle pins the M5 Phase 1 deliverable itself: the +// /tinkerdown:save instructions must tell a capture to carry the house style, not just +// sources + actions. This is a structural mention-check (in the spirit of +// TestSkillWiresHouseStyle) — it is the only guard on the prose edit, since the +// round-trip above guards the *example artifacts*, not the *instructions*. It does not, +// and cannot, prove a future LLM capture obeys; it only fails loudly if the instruction +// is reverted so the omission returns silently. +func TestSaveSkillCapturesHouseStyle(t *testing.T) { + const savePath = "skills/tinkerdown-save/SKILL.md" + doc := readDocFile(t, savePath) + for _, want := range []string{"styling", "style_guide", "style-guide.md", "on-brand"} { + if !strings.Contains(doc, want) { + t.Errorf("%s does not instruct capturing the house style: missing %q", savePath, want) + } } } diff --git a/cmd/tinkerdown/commands/cli.go b/cmd/tinkerdown/commands/cli.go index b406c2aa..32a4607e 100644 --- a/cmd/tinkerdown/commands/cli.go +++ b/cmd/tinkerdown/commands/cli.go @@ -16,6 +16,7 @@ import ( "github.com/livetemplate/tinkerdown/internal/config" "github.com/livetemplate/tinkerdown/internal/source" + "github.com/livetemplate/tinkerdown/internal/wasm" ) // defaultTimeout is the default context timeout for CLI operations @@ -126,11 +127,11 @@ func CLICommand(args []string) error { // cliOptions holds parsed command-line flags type cliOptions struct { - format string // Output format: table, json, csv - filter string // Filter expression: field=value - id string // Item ID for update/delete - yes bool // Skip confirmation - fields map[string]interface{} // Field values for add/update + format string // Output format: table, json, csv + filter string // Filter expression: field=value + id string // Item ID for update/delete + yes bool // Skip confirmation + fields map[string]interface{} // Field values for add/update } // parseFlags parses command-line flags @@ -234,6 +235,15 @@ func createCLISource(name string, cfg config.SourceConfig, siteDir, currentFile return source.NewPostgresSourceWithConfig(name, cfg.Query, cfg.Options, cfg) case "graphql": return source.NewGraphQLSource(name, cfg, siteDir) + case "wasm": + // WASM has full CLI parity with `serve`: a module that exports `write` gets + // add/update/delete, and a read-only one (no `write` export) lists only — + // NewWasmSource derives writability from the module (WasmSource.IsReadonly), + // so the cliAdd/cliUpdate/cliDelete readonly guard applies uniformly. + return wasm.NewWasmSource(name, cfg.Path, siteDir, cfg.Options) + // exec is intentionally not constructed here: it is a read-oriented command + // runner gated by --allow-exec, not a CRUD store, so it has no place in the + // `tinkerdown cli` add/update/delete surface. This omission is deliberate. default: return nil, fmt.Errorf("unsupported source type for CLI: %s", cfg.Type) } diff --git a/cmd/tinkerdown/commands/cli_wasmsource_test.go b/cmd/tinkerdown/commands/cli_wasmsource_test.go new file mode 100644 index 00000000..8ed19187 --- /dev/null +++ b/cmd/tinkerdown/commands/cli_wasmsource_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "context" + "strings" + "testing" + + "github.com/livetemplate/tinkerdown/internal/config" + "github.com/livetemplate/tinkerdown/internal/source" +) + +// TestCLIWasmSourceReadParityAndReadonlyGuard verifies the wasm CLI parity added in +// M5 Phase 3: createCLISource now constructs a wasm source (it previously fell through +// to "unsupported source type for CLI"). It exercises the whole surface that IS +// verifiable in a TinyGo-free environment, against the committed read-only quotes +// module: +// - the source constructs and Fetches — the read path actually reaches the CLI; and +// - the readonly guard holds: a module with no `write` export reports IsReadonly() +// true, and WriteItem refuses with a guard error rather than silently dropping a +// write. +// +// A *successful* write needs a module that exports `write`, which requires TinyGo to +// build (absent here). The only committed .wasm is read-only, and no test in any +// transport — CLI or WebSocket — exercises a successful write. That gap is tracked as +// a follow-up (see the plan's M5 Phase 3 Learn / #216). Do not "verify" what cannot be +// run. +func TestCLIWasmSourceReadParityAndReadonlyGuard(t *testing.T) { + const wasmDir = "../../../examples/lvt-source-wasm-test" + cfg := config.SourceConfig{ + Type: "wasm", + Path: "./sources/quotes.wasm", + Options: map[string]string{"category": "inspiration"}, + } + + src, err := createCLISource("quotes", cfg, wasmDir, "") + if err != nil { + t.Fatalf("createCLISource(wasm) — the parity gap this phase closes: %v", err) + } + defer src.Close() + + ctx := context.Background() + rows, err := src.Fetch(ctx) + if err != nil { + t.Fatalf("wasm Fetch (read parity): %v", err) + } + if len(rows) == 0 { + t.Error("wasm source returned no rows — the read path is not actually running") + } + + writable, ok := src.(source.WritableSource) + if !ok { + t.Fatal("wasm source does not implement WritableSource") + } + if !writable.IsReadonly() { + t.Error("the quotes module exports no `write`, so IsReadonly() should report true") + } + err = writable.WriteItem(ctx, "add", map[string]interface{}{"text": "x"}) + if err == nil { + t.Fatal("WriteItem on a read-only module must error, not silently succeed") + } + if !strings.Contains(err.Error(), "does not support write") { + t.Errorf("WriteItem error = %q, want the 'does not support write operations' guard", err) + } +} diff --git a/cmd/tinkerdown/commands/templates/wasm-source/test-app/tinkerdown.yaml b/cmd/tinkerdown/commands/templates/wasm-source/test-app/tinkerdown.yaml index 14523627..fa52872b 100644 --- a/cmd/tinkerdown/commands/templates/wasm-source/test-app/tinkerdown.yaml +++ b/cmd/tinkerdown/commands/templates/wasm-source/test-app/tinkerdown.yaml @@ -1,4 +1,4 @@ sources: data: type: wasm - module: ../source.wasm + path: ../source.wasm diff --git a/docs/plans/2026-07-09-ephemeral-ui-reframe.md b/docs/plans/2026-07-09-ephemeral-ui-reframe.md index 1ec091c4..f113b46d 100644 --- a/docs/plans/2026-07-09-ephemeral-ui-reframe.md +++ b/docs/plans/2026-07-09-ephemeral-ui-reframe.md @@ -21,7 +21,7 @@ What this reframe ships, most-important first. **M1 is the headline** (makes the **M0 — foundation:** repositioned narrative (README/SKILL/llms.txt) + upstream version bump to latest (`livetemplate` v0.10→latest, client, components). -**M2–M5 — hardening:** `livetemplate.Validate()` API + policy lint *(M2, upstream + tinkerdown)* · runtime approved-surface enforcement *(M3, tinkerdown; field-name validation deferred — needs a schema source)* · house style on-brand by construction — formalized design tokens + wired `style_guide` *(M4, tinkerdown; no lint)* · save/share gallery (stores captured skills) + external-embed handshake *(M5)*. +**M2–M5 — hardening:** `livetemplate.Validate()` API + policy lint *(M2, upstream + tinkerdown)* · runtime approved-surface enforcement *(M3, tinkerdown; field-name validation deferred — needs a schema source)* · house style on-brand by construction — formalized design tokens + wired `style_guide` *(M4, tinkerdown; no lint)* · save & share the captured skill — capture-completeness + a dogfooded gallery + #216 CLI parity *(M5, tinkerdown; spine-only, share = git; embed/review/hosted deferred)*. --- @@ -109,7 +109,7 @@ The brief names four things a team defines once so LLMs can safely generate agai | 2 | No **field-name validation**: a misspelled `{{.Field}}` renders empty. (Action-name/`describes:` metadata already exists in `config.Summarize`.) | **Deferred** (M3 kickoff 2026-07-24): needs a *validate-time schema source* tinkerdown lacks — schema lives only in the runtime DB (gitignored, seed-built). Real fix = parse `CREATE TABLE` from a seed, or a declared `columns:` schema — a new introspection capability, not a lint. | `validate` catches a bad field before serve | **Deferred** (§ Risks) | | 3 | Runtime enforcement is generation-time only; a webhook / bypassing caller can invoke outside the approved surface (three action paths, no shared gate) | **Tinkerdown** policy gate at all three paths (kickoff 2026-07-24: an upstream `WithActionPolicy` is dead code — tinkerdown never uses livetemplate dispatch) | Runtime safety (defense in depth) | **M3** | | 4 | House style can't be guaranteed on-brand: the design-token layer is duplicated/partial + `style_guide` is a declared-but-unconsumed config field. (The `lvt/components` library is deep — ~29 components — but tinkerdown uses 1; enrichment is optional, not the gap.) | **Tinkerdown** (kickoff 2026-07-24): formalize the tokens + wire `style_guide` into generation — on-brand by construction, no lint | Guaranteed on-brand, no generic LLM aesthetic | **M4** | -| 5 | External-app embedding is iframe-bridge only | Embed handshake [client] | Sandboxed external embeds | **M5** | +| 5 | External-app embedding: today's `embed-lvt` is inline (not iframe) + unsandboxed + LiveTemplate-specific | Sandboxed any-framework embed handshake [client] | Sandboxed external embeds | **Deferred → [#249](https://github.com/livetemplate/tinkerdown/issues/249)** (design-only upstream; dropped from M5 at kickoff 2026-07-24) | | 6 | Generation→validate→serve **orchestration** | **Legitimately tinkerdown** (all 3 exploration agents concur) | The skill itself | **M1** | **The load-bearing decision (per operator sign-off):** M1 ships the demo on primitives that already exist (items 6 + M0's bump); items 1–5 are explicit *hardening* milestones **after** the demo runs. This is a deliberate sequencing choice, not a shortcut — the generation orchestration is the one layer that always lived in tinkerdown. @@ -168,7 +168,7 @@ The brief names four things a team defines once so LLMs can safely generate agai **upstream (fix here per the rule):** - `../livetemplate` — version bump (M0); `Validate()` API (M2). *(M3 is tinkerdown-only — the planned upstream `WithActionPolicy`/introspection would be dead code; see § M3 phases.)* -- `../client` — consume via bump (M0); embed handshake (M5). +- `../client` — consume via bump (M0). *(The embed handshake originally planned for M5 was deferred at kickoff → [#249](https://github.com/livetemplate/tinkerdown/issues/249); the re-scoped M5 is tinkerdown-only.)* - `../lvt/components` — enrich vocabulary (M4). --- @@ -187,17 +187,17 @@ All follow the Anthropic skill shape already used by `skills/tinkerdown/` (conci ## Roadmap -Ordered milestones. **M0, M1, M2, M3 and M4 are fully detailed** (full phase blocks); M5 is outline-only and gets expanded at its kickoff (see LLM session guide convention 9). +Ordered milestones. **M0–M5 are all fully detailed** (full phase blocks) — M5 expanded at kickoff 2026-07-24 (convention 9), re-scoped to a tinkerdown-only, spine-only "save & share the captured skill." - **M0 — Reframe + upstream bump + a trustworthy vocabulary.** Reposition the narrative (README/SKILL/llms.txt/ai-generation) around ephemeral generated UIs; **bump `livetemplate` + client + components to their latest tags** and absorb behavior changes; **reconcile the `lvt-*` attribute reference with what the client actually implements** (Phase 2 — added after Phase 1's Audit found 8 of 11 sampled attributes stale). *Lights up:* the fast, correct runtime substrate, the honest story, and a vocabulary reference that is safe to hand an LLM — the last being a hard prerequisite for M1, not polish. *(no demo yet)* - **M1 — THE DEMO (thin vertical slice).** The five M1 deliverables (see § Deliverables at a glance) on existing primitives. *Lights up:* the target acceptance test — generate a real, friction-removing UI in ~30s — *and* re-run it from a skill in seconds. - **M2 — Deterministic validation (upstream `Validate()` + tinkerdown lint).** `livetemplate.Validate(templateText)` exposes the **structured, line-numbered parse / reactive-AST diagnostics** that today live in livetemplate's `internal/parse` (unreachable downstream) and that its public render path silently *swallows* — plus an optional render-determinism check. tinkerdown's `validate` consumes it per lvt block and adds the **tinkerdown-owned** semantic checks livetemplate cannot see: **bound-refs** (a used `lvt-source` must resolve to a declared source), **action-param completeness**, a **state-ref diagnostic that teaches the real `lvt-source` fix**, and the **`lvt-persist`→removed-with-migration** cleanup. *Lights up:* first-pass generation reliability — richer diagnostics for the skill's self-correct loop, and the "validates clean but breaks at serve" gaps M1 kept hitting closed at the gate. *(The attribute allowlist stays in tinkerdown, guarded against the vendored client bundle — livetemplate-Go can't own it, as it never references the client-only `lvt-mod:`/`lvt-nav:`/`lvt-ignore` namespaces. Design expanded at kickoff 2026-07-23 — see § M2 design.)* - **M3 — Runtime approved-surface enforcement (tinkerdown).** A server-side policy gate at all three action paths (WS-custom, webhook, builtins) so a running app — or a webhook / crafted-message caller — may only invoke **approved** actions against **approved** sources, respecting readonly: defense-in-depth beyond the *generation-time* lint. *Lights up:* the running app can't exceed the approved surface — the server-side gate `confirm:` never was. *(Kickoff 2026-07-24 re-scoped this **tinkerdown-only** — the planned upstream `WithActionPolicy` would be dead code for tinkerdown, which never routes actions through livetemplate. **Field-name validation was de-scoped** — it needs a validate-time schema source tinkerdown lacks; see § M3 phases and § Risks.)* - **M4 — House style on-brand by construction (tinkerdown).** Formalize the design-token layer that already exists implicitly (single-source it, define the four undefined tokens, drive it from a `tokens` config) so semantic HTML + PicoCSS renders on-brand **by construction**; and wire the **declared-but-unconsumed** `style_guide` into the generation context. *Lights up:* guaranteed on-brand generated UIs — no generic LLM aesthetic — via tokens, not a rejecting lint. *(Kickoff 2026-07-24 re-scoped this **tinkerdown-only** [tinkerdown consumes 1 of `lvt/components`' ~29 components], **spine-only** [component enrichment deferred, operator decision], and **no enforcement lint** [it fights semantic-HTML-first]; see § M4 phases.)* -- **M5 — Persist to the malleable substrate (save & share).** This is where **malleability lives**: persist a generated UI to the repo + a gallery + share link by storing the captured **skill** (per convention 13 / M1 Phase 6), not just the `app.md` — so "save" means a re-runnable workflow you evolve, while individual UIs stay ephemeral; plus **substrate extensibility** (teams can add their own approved source types) and the external-app embed handshake. *Lights up:* "throw away the UI, keep + reshape the substrate" (folds issues #223 host read-only apps, #282 review mode, #249 external embed, #216 writable WASM sources, #222 custom Go+WASM sources). +- **M5 — Save & share the captured skill.** This is where **malleability lives**: a generated UI worth keeping is captured as a git-committed **skill** (per M1 Phase 6), not just an `app.md` — so "save" means a re-runnable workflow you evolve, while individual UIs stay ephemeral. *(Kickoff 2026-07-24 re-scoped this **tinkerdown-only, spine-only**, operator decision: **save = real** — complete the capture so the house style travels [M4 feed-forward] + a **dogfooded gallery** listing the committed skill dirs + close out **#216** writable-WASM CLI parity; **share = git portability** [commit → pull → re-run], *not* a hosted service or share-link subsystem; see § M5 phases.)* *Lights up:* "throw away the UI, keep + reshape the substrate." *Deferred as named follow-ups (not in M5):* #282 review-mode (collaborative share), #249 sandboxed any-framework embed (design-only upstream, security-sensitive), #223 hosted service (no infra for a CLI/library), #222 custom Go source types (the unresolved build-time source-registration decision). **Open-issue walkthrough.** Triaged into three buckets against this reframe. *(Counts drift: **48 open** at plan authoring, **82** by 2026-07-19 — the repo files `from-review` issues continuously. The named issue→milestone mappings below stay valid; **re-run the triage at M0 kickoff** rather than trusting the totals, same as the version pins.)* -- **Folded into milestones (reframe-aligned):** #223 host read-only apps → **M5** gallery; #282 review mode → **M5**; #249 `lvt-external` embed any framework → **M5** embed handshake; #216 writable WASM sources → **M5** sources; #222 custom sources in Go+WASM → **M5**; #226 ROADMAP lifecycle-attr pattern + #230 `lvt-preserve`→`lvt-ignore` doc fixes → **M0** reframe pass. +- **Folded into milestones (reframe-aligned):** #216 writable WASM sources → **M5 Phase 3** (verify + CLI parity + docs — already implemented in code); #226 ROADMAP lifecycle-attr pattern + #230 `lvt-preserve`→`lvt-ignore` doc fixes → **M0** reframe pass. **Deferred out of M5 at kickoff 2026-07-24 (remain open, named follow-ups):** #223 host read-only apps (hosted service, no infra), #282 review mode (collaborative share — a standalone feature), #249 `lvt-external` embed (design-only upstream, security-sensitive), #222 custom Go source types (unresolved build-time source-registration). *(M5 re-scoped to the in-grain "save & share the captured skill" spine — see § M5 phases.)* - **Reliability/tech-debt, pulled in only if a phase's Audit finds it blocking:** #269 hot-reload serves stale `/`, #275 Kroki timeout blocks discovery, #259 multi-range highlight overlay misalign (all P2) — none sit on the M1 critical path, but #269 touches serve/hot-reload which M0's bump also touches, so M0 Audit checks it. - **Orthogonal `from-review` follow-ups (~35 at authoring and growing — #258–#273 etc., mostly P3/P4):** refactors/test-coverage/perf nits with no bearing on the reframe. Not scheduled here; left to normal backlog grooming. Explicitly *out of scope* so the reframe stays a thin vertical, not a debt-paydown. @@ -1081,12 +1081,115 @@ type Diagnostic struct { --- -### M5 phases — outline only (expanded at milestone kickoff per convention 9) +### M5 phases — save & share the captured skill (expanded at kickoff 2026-07-24, per convention 9) -The *what* + *why* of each is in § Roadmap; here is only the milestone's **kickoff design checklist** — the sections to write into full phase blocks when it starts (per convention 9): +*(M2, M3, M4 were also expanded to full phase blocks at kickoff — see § M2/M3/M4 phases above. Re-scopes recorded there: M3's upstream `WithActionPolicy` dropped as dead code + field-name validation deferred; M4 re-scoped tinkerdown-only, spine-only, no enforcement lint.)* -*(M2, M3, M4 were expanded to full phase blocks at kickoff — see § M2/M3/M4 phases above. Re-scopes recorded there: M3's upstream `WithActionPolicy` dropped as dead code + field-name validation deferred; M4 re-scoped tinkerdown-only, spine-only, no enforcement lint — the on-brand vision delivered by tokens, not rejection.)* -- **M5** (tinkerdown + `client`): the persistence model (stores captured *skills*, not just `app.md`); gallery UX; the external-embed handshake protocol; **substrate extensibility** — writable WASM sources (#216) + custom Go+WASM source types (#222), and how a team-authored source enters the *approved* set. +> **Milestone shape (settled at kickoff 2026-07-24):** **tinkerdown-only, spine-only** — "save & share" delivered as **save-is-real + share-via-git**, not a hosted service or a collaboration subsystem (operator decision: the in-grain spine over the bigger capstone). A direct read (Explore 2026-07-24) found the plan's five folded features heterogeneous and mostly mis-framed vs reality: +> - **Persistence is greenfield, but the durable *unit already exists.*** The "persist to the repo" the plan wants **is git-committed skill dirs** (`skills//`, already produced by `/tinkerdown:save` per M1 Phase 6). `playground.go`'s 1-hour in-memory RAM store is a paste-preview sandbox — irrelevant to save/share. So the gallery is a **read-only listing** over the committed skill dirs, *not* a new persistence subsystem. +> - **Capture omits the house style.** `/tinkerdown-save` captures approved sources + actions but **not** `styling`/`generation`/`style_guide` (the M4 Phase 2 feed-forward, § M4 Phase 2 Learn). Small, real, in-grain. +> - **#216 writable WASM is already implemented** (`internal/wasm/source.go` `WriteItem`/`IsReadonly`). Honest disposition: **verify it runs + fix the one real residual** (`cmd/tinkerdown/commands/cli.go:221-240` `createCLISource` omits `wasm`/`exec`, so writable WASM works over WebSocket but not via `tinkerdown cli`) **+ docs** — *not* "build it", *not* "close untouched." +> - **The embed framing is wrong twice over.** Today's `embed-lvt` (repo-root `embed_lvt.go`) is **inline, not iframe**, *and* **unsandboxed** (server-fetches + inlines untrusted upstream HTML into the docs DOM, forwarding cookies). #249's real ask (any-framework, *sandboxed*) is a different, security-sensitive mechanism, and #249 is a **design-only** issue upstream. Deferred. +> +> **What "share" means (honest headline — no overclaim):** save is real — a captured skill re-runs on-brand with **no LLM generation**. **Share = git portability:** commit the skill dir → a teammate pulls → re-runs. There is **no hosted service, no share-link subsystem, and no collaborative review** in M5; those are the deferred items. +> +> **Deferred as named follow-ups** (recorded, not pretend-in-scope — each stays its own open issue): **#282** review-mode (collaborative "share a UI, collect comments" — the *heart of a bigger share*, a standalone feature; the operator chose the spine over pulling this in); **#249** sandboxed any-framework embed (design-only upstream, security-sensitive, would bump `@livetemplate/client` + regen the bundle); **#223** hosted service (`tinkerdown.town/...` — no infra for a CLI/library); **#222** custom Go source types (forces the unresolved **build-time source-registration** decision — the type set is a hardcoded `switch`, `internal/source/source.go:224`). Substrate **approval** already exists (`Generation.Sources` allowlist, `config.go` `ApprovedSource`); only **registration** of a new *type* is unbuilt, and that is #222/#249's open question, not M5's. +> +> **No client bump.** With embed deferred, M5 touches **no** `@livetemplate/client` code — so the § *Standing Audit item … artifact provenance* checklist (which the plan tagged M5 with under the old embed scope) **does not apply** to this re-scoped M5. + +#### Phase 1 (M5, tinkerdown) — Capture completeness: the house style travels (~1 session) + +> **Goal at end:** `/tinkerdown-save` captures the *whole* relevant manifest — approved sources + actions **and the house style** (`styling` incl. `tokens`, `generation.style_guide` + the referenced `style-guide.md`) — so a saved skill re-runs **on-brand and self-contained**, closing the M4 Phase 2 feed-forward. + +**Design refs:** +- § M4 Phase 2 Learn (the feed-forward: "a saved skill should capture its manifest's `styling`/`style_guide` so the palette travels") +- `skills/tinkerdown-save/SKILL.md` (§ *What to extract* step 2 "The manifest" — captures sources + actions, omits styling); `examples/pii-access-approval/` (the committed capture target — post-M4 it carries `styling.tokens` + `generation.style_guide` + `style-guide.md`); the M4 `TestPIIManifestHouseStyleConsumable` pattern (tie the test to real config state, not word-presence) + +**Audit (first task):** +- [x] **Design-ref completeness check.** Done — all refs read (both SKILL.md files, the post-M4 example manifest, `TestPIIManifestHouseStyleConsumable`, `TestCapturedSkillsWellFormed`). +- [x] Confirm `SKILL.md` step 2 omits `styling`/`generation`/`style_guide` — **confirmed** (step 2 captured only "approved sources and named actions"). The committed `skills/pii-access-approval` **points at** `examples/pii-access-approval/` (the "Run it" prose) whose manifest carries the house style post-M4 — **confirmed** (`styling.tokens` accent `#3949ab` + `generation.style_guide: style-guide.md`, `style-guide.md` 1831 B on disk). +- [x] Decide the test tie — **chose (a)** (real-state `config.LoadFromDir`, hardcoded fixture path, no prose-scrape), **deferred (b)** to Phase 2. Advisor sharpened *why*: house style **already travels for the committed *point* case** (`serve` loads the whole `tinkerdown.yaml`), so the round-trip ≈ M4's and passes with zero Phase-1 code — the gap the prose edit actually closes is the **bundle** case (a hand-assembled throwaway skill dir that trims the yaml or drops the separate `style-guide.md`). A machine-readable `manifest:` pointer (option b) is scope Phase 2's gallery didn't ask for (convention 6); if the gallery wants to link each skill to its manifest, *that* is where (b) earns its place — decided with the gallery's real read-needs, not speculatively here. Round-trip **folded into `TestCapturedSkillsWellFormed`** (anchored on the captured skill dir) rather than a dangling clone of `TestPIIManifestHouseStyleConsumable`. +- [x] Confirm the `style-guide.md` file is captured under the same **bundle-vs-point** rule as `seed.sql`/`app.md` — **confirmed, no new rule**: added `style-guide.md` to the bundle-artifacts list, and it travels under the existing point rule for the committed pii example. + +**Implementation:** +- [x] `skills/tinkerdown-save/SKILL.md` step 2: extend "The manifest" to capture the **house style** — `styling` (incl. `tokens`), `generation.style_guide`, and the referenced `style-guide.md` file (bundle-or-point). Verify-checklist item 4: the captured manifest carries the style the app ran on. Bundle list gains `style-guide.md`. +- [x] `CHANGELOG.md` — "a saved skill now carries its house style" under `[Unreleased]`. + +**Acceptance criteria:** +- [x] **Simplify:** prose pass — step 2 reads as rule → consequence (why a stripped manifest re-runs off-brand); diff is minimal (docs + one test file). +- [x] **Unit/structural:** `TestCapturedSkillsWellFormed` extended — the `pii-access-approval` skill's referenced manifest round-trips `styling.tokens` + `generation.style_guide` via `config.LoadFromDir` (real state, hardcoded path, not a `SKILL.md` substring). Plus `TestSaveSkillCapturesHouseStyle` — a thin structural pin on the *instruction* (the only guard on the prose deliverable itself). Green. +- [x] **Integration:** the round-trip also asserts `style-guide.md` resolves + tokens populate (folded into the same test) — the saved workflow is on-brand by construction. Green. +- [x] **E2E:** N/A (capture is a skill-doc + manifest; the on-brand *render* guarantee is M4's tokens, already E2E-verified). + +**Learn:** +- **What surprised us:** the point-vs-bundle asymmetry (advisor). The real-state round-trip **passed before any Phase-1 code** — because for a committed *point* capture, `styling`/`generation` are top-level blocks that `serve` loads whole, so house style already travels. This phase's actual deliverable is therefore a **prose instruction** governing the *bundle* (throwaway) case, whose effect on future LLM captures is **untestable** by construction. The genuinely-new deterministic guards are narrow: the `style-guide.md` **ref** (catches the style file failing to travel) and the structural instruction-pin (catches the edit being reverted). The token round-trip is honest M4 overlap, framed from the capture angle. +- **Plan drift fixed:** none material — the Audit already anticipated the ≈M4 overlap and the prose-scrape trap; executed option (a) as written and recorded the (a)-over-(b) decision inline above. +- **Feed-forward to Phase 2's Audit:** the deferred machine-readable **skill→manifest pointer** (option b) is Phase 2's to decide *if* the gallery needs to link a listed skill to its stand-up manifest — decide it with the gallery's real read-needs, not speculatively. The gallery reads `SKILL.md` frontmatter (`name`/`description`/`triggers`); the captured pii skill's frontmatter is confirmed well-formed by `TestCapturedSkillsWellFormed`. +- **New/changed risks:** none new. The "a skill-doc prose edit's future-LLM effect is untestable" limit is inherent to instruction changes and is now stated explicitly rather than papered over. + +#### Phase 2 (M5, tinkerdown) — The gallery: a dogfooded index of saved skills (~1 session) + +> **Goal at end:** a **gallery** — itself a Tinkerdown app (dogfooded, convention 12) — lists the committed `skills//` dirs with each `SKILL.md`'s `name`/`description`/`triggers`, so "save if the need recurs" has a discoverable home. Individual UIs stay ephemeral; the gallery indexes the durable captured **skills**. +> +> **Settled at execution (2026-07-24) — STOP gate hit, then user-ratified fallback (b).** The Audit's discriminating question came back **no**: no source type reads a *directory* of `SKILL.md` files, and none parses YAML *frontmatter* (verified — no `ReadDir`/glob in `internal/source/`, all file sources take a single `file`). So a *dynamic* directory index isn't buildable in-grain without `exec` (friction on a listing page). Fallback (a) was rejected — `tinkerdown build` is an app→binary *compiler*, not an index generator, so (a) means *new* machinery; (c) a directory-frontmatter source is a genuine new primitive (#222-adjacent), disproportionate here. Because this was a **material divergence** (the plan imagined a dynamic index; what's in-grain is a hand-maintained *static* list, and only **one** captured skill exists), it was put to the operator (conventions 2 + 8), who chose **build the minimal static-index gallery now**. So the shipped gallery renders a committed `examples/gallery/skills.csv` via a read-only `csv` source + an `lvt-source` template (no exec, no new source, no custom JS), with a sync-guard test keeping the index == the captured-skill set on disk. + +**Design refs:** +- § Roadmap M5 ("throw away the UI, keep + reshape the substrate"); convention 12 (dogfood the ecosystem — no custom JS, an existing source type only) +- `internal/source/` (the closed source-type set: `sqlite`/`pg`/`rest`/`json`/`csv`/`markdown`/`exec`/`wasm`/`graphql`/`computed`, dispatch at `source.go:224`); `skills/*/SKILL.md` frontmatter (the listing's data); M1 Phase 6 Learn (the capture is the gallery's unit — do not re-litigate) + +**Audit (first task) — the in-grain gate is the whole risk of this phase:** +- [x] **Design-ref completeness check.** Done — source dispatch (`source.go:224`), the file sources, `tinkerdown build`, and the file-source example all read. +- [x] **Discriminating question (advisor): can the gallery be built with an *existing* source type over `skills/`?** **Answered: no** (verified, not inferred). No `filepath.Glob`/`Walk`/`os.ReadDir` anywhere in `internal/source/`; all file sources (`json`/`csv`/`markdown`) take a single `file`, and none parses YAML frontmatter. Only `exec` could scan `skills/` — the `--allow-exec` friction the gate forbids on a listing page. **STOP gate hit.** (a) rejected: `tinkerdown build` is an app→binary compiler (`generateBuildSource(isDir)` embeds pages for compilation), not an index generator — (a) = new machinery. (c) rejected: a directory-frontmatter source is a real new primitive (#222-adjacent), disproportionate. → **fallback (b) static committed index**, ratified by the operator (see Goal note). +- [x] Decide where the gallery lives + which skills it lists. **Lives at `examples/gallery/`** (a real dogfooded app). **Lists only captured *workflow* skills** — `skills/` dirs minus the explicit framework set `{tinkerdown, tinkerdown-save}` (authoring tooling, not user workflows). The framework set is **explicit, not auto-detected** (advisor: don't invent a frontmatter marker / detection heuristic — same trap as Phase 1's deferred option b); a new capture fails the sync test until listed — a forced decision, never silent drift. + +**Implementation:** +- [x] The gallery app (dogfooded — `lvt-source` over a read-only `csv`, no custom JS): `examples/gallery/{tinkerdown.yaml, index.md, skills.csv, README.md}`. Lists each captured workflow's name / description / triggers / **Location** (the `skills//` path to its stand-up steps). **Named `index.md`, not `app.md`** — the canonical index route `/` (see Learn: `app.md` serves at `/` only by discovery-order luck). +- [x] `CHANGELOG.md`. + +**Acceptance criteria:** +- [x] **Simplify:** `/simplify` the diff. +- [x] **Unit/structural:** `TestSavedSkillsGalleryInSync` asserts the committed index lists exactly `skills/` minus the framework set (the sync-guard); `TestSavedSkillsGalleryManifestLoads` asserts the manifest loads with the read-only `csv` source; `tinkerdown validate examples/gallery/index.md` clean. Green. +- [x] **Integration:** the manifest-loads test + the E2E serve prove it stands up; the gallery renders the captured-skill entry with its description. Green. +- [x] **E2E (chromedp, four-channel):** `TestSavedSkillsGalleryRenders` — served gallery renders `pii-access-approval` + a slice of its description + its reachable `skills/pii-access-approval` location, via the real `lvt-source` path; console + server log + WS frames + rendered HTML + screenshot captured (the HTML channel is what caught the wrong-page bug). Green (12s). + +**Learn:** +- **What surprised us (did the in-grain gate hold?):** the gate held and *fired* — the honest answer was "not buildable dynamically in-grain," and the discipline was to STOP and ask rather than force `exec` or invent a primitive. Two concrete surprises: (1) `tinkerdown build` is a *compiler*, not the index generator fallback (a) assumed — so (a) was never the cheap option. (2) An `app.md` page is served at `/` only by *discovery-order luck* (pii gets away with it; the gallery's `README.md` won `/` instead, rendering the wrong page). The four-channel **rendered-HTML** capture is what pinned it — "0 blocks / 0 WS frames" alone read as a client failure; the HTML showed README content. Canonical `index.md` is the fix. +- **Plan drift fixed:** the Goal's "dynamic directory index" was aspirational; recorded the STOP-gate re-scope to a static committed index inline (Goal note + Audit) in this commit. Also generalized fallback (a)'s "`tinkerdown build`-adjacent" phrasing — `build` can't do it. +- **Feed-forward to Phase 3's Audit:** none blocking. Phase 3 (#216 writable-WASM CLI parity) is independent of the gallery. Note for any *future* substrate-extensibility work: the STOP gate here is more evidence that "read a directory of files / parse frontmatter" is an unbuilt source primitive (#222-adjacent) — the same registration gap #222/#249 defer. +- **New/changed risks:** the gallery's per-skill description text is maintained in `skills.csv` in parallel with each `SKILL.md` frontmatter; the sync-guard covers the *set* of listed skills (the drift that matters — an unlisted capture), **not** description-text drift (low-harm cosmetic). Stated plainly rather than papered over; a full frontmatter-consistency guard would require the very frontmatter-parsing no source has. + +#### Phase 3 (M5, tinkerdown) — #216 disposition: writable-WASM CLI parity + docs (~1 session) + +> **Goal at end:** writable WASM has **CLI parity** — the already-implemented `WritableSource` WASM path is *verified running*, `tinkerdown cli`'s CRUD reaches it (the `createCLISource` gap closed, or `exec`/`wasm` omission documented as intentional), the wasm-source template + `docs/sources/wasm.md` reflect writability, and **#216 is closed with an honest disposition** — not left "done in code, unverified." +> +> **Settled at execution (2026-07-24) — the write-verification premise was wrong (advisor-confirmed).** The Audit's "verify a write lands" assumed `examples/lvt-source-wasm-test/` was a *writable* path; it is **read-only** — `quotes.wasm` exports `fetch`/`get_result_len`/`free_result` but **no `write`**, so `IsReadonly()` correctly returns true and no write can be exercised. It is the **only** `.wasm` in the repo, there is **no TinyGo** here to build a write-exporting fixture, and standard-Go `wasip1` is ABI-incompatible with the loader's fixed-offset `//export` model (which is *why* the template mandates TinyGo). A committed writable `.wasm` I can't rebuild would be the exact **artifact-provenance trap** #295/#297 already fought. So a *successful* write is **not empirically verifiable in this environment**, in WS *or* CLI — and the plan's "works over WebSocket, just not CLI" premise is itself unbacked (no test exercises a successful write in any transport). The **honest #216 disposition** is therefore: CLI parity gap closed; read path + readonly-guard verified; successful-write needs a **reproducible TinyGo fixture wired into `make`** — a follow-up, not this session. **#216 is not closed here** (an outward action gated on PR signoff); the disposition is presented at signoff. + +**Design refs:** +- [#216](https://github.com/livetemplate/tinkerdown/issues/216) (writable WASM sources, Phase C2) +- `internal/wasm/source.go` (`WriteItem`/`IsReadonly` — already implement `WritableSource`); `cmd/tinkerdown/commands/cli.go:221-240` (`createCLISource` — omits `wasm`/`exec`); `examples/lvt-source-wasm-test/` (the runnable path); `cmd/tinkerdown/commands/templates/wasm-source/` + `docs/sources/wasm.md` (the residual doc/template surface the issue names) + +**Audit (first task):** +- [x] **Design-ref completeness check.** Done — `internal/wasm/source.go` (`WriteItem`/`IsReadonly`), `createCLISource`, the example, template, and doc all read. +- [x] **Verify, don't infer (advisor):** ran it — and found the premise wrong. `examples/lvt-source-wasm-test/` is **read-only** (`quotes.wasm` has no `write` export → `IsReadonly()==true`); it is the only `.wasm` in the repo; **no TinyGo** to build a write-exporting fixture. So a *successful write* cannot be exercised (see Goal note). What **is** verified running: the wasm **read** path via CLI (`tinkerdown cli … list quotes` returns rows) and the **readonly guard** (`WriteItem` refuses with "does not support write operations"). +- [x] Confirm the `createCLISource` gap and decide per type. Confirmed `wasm` + `exec` both absent. **`wasm` → added** (writability derived from the module, so the existing readonly guard applies uniformly). **`exec` → documented intentional omission** (a comment in the switch): it is a read-oriented, `--allow-exec`-gated command runner, not a CRUD store. +- [x] Read #216's residual (template + doc) and reconcile. Found the doc/template use a **non-existent `module:` key** (the real key is `path:`) and `config:` (real: `options:`) — a load-breaking doc bug, fixed here alongside the writability note. + +**Implementation:** +- [x] `createCLISource`: added `wasm` → `wasm.NewWasmSource(...)`; `exec` left out with an explanatory comment (not a silent `default:`). +- [x] Updated `docs/sources/wasm.md` (fixed `module:`→`path:`, `config:`→`options:`; added a **Read-only vs. writable** section documenting the `write`-export contract + CLI parity) and `templates/wasm-source/test-app/tinkerdown.yaml` (`module:`→`path:`). +- [x] `CHANGELOG.md`. **#216 NOT closed here** — the honest disposition (parity closed; write-landing unverifiable without a TinyGo fixture) is presented at PR signoff, per the delivery protocol. + +**Acceptance criteria:** +- [x] **Simplify:** `/simplify` the diff. +- [x] **Unit:** `TestCLIWasmSourceReadParityAndReadonlyGuard` — `createCLISource(wasm)` constructs + `Fetch`es (read parity), `IsReadonly()==true` for the no-`write`-export module, and `WriteItem` returns the guard error. Green. (A *successful* write is not unit-testable here — no writable module.) +- [x] **Integration:** `tinkerdown cli examples/lvt-source-wasm-test list quotes` returns 5 rows end-to-end (the real binary, not inferred). `add` against this read-only module is correctly refused. +- [x] **E2E:** N/A (CLI). The browser/WS *write* path is **also** unverified (no writable fixture in any transport) — recorded honestly, not claimed. + +**Learn:** +- **What surprised us (did writable WASM actually run?):** the honest answer is **the read path runs; a successful write was never runnable here** — the plan (and #216's "works in WS, just not CLI") assumed a writable fixture that does not exist. Two footguns surfaced: (1) a test file named `cli_wasm_test.go` is **silently excluded** from non-`wasm` builds — `wasm` is a `GOARCH`, so `*_wasm_test.go` is an implicit build constraint; renamed to `cli_wasmsource_test.go` (caught by `go list -f {{.TestGoFiles}}` showing "[no tests to run]"). (2) the wasm **docs/template used `module:`/`config:` keys that don't exist** (`path:`/`options:` do) — following the docs would fail to load; fixed. +- **Plan drift fixed (in-commit):** the "verify a write lands" premise + the "writable path exists at `examples/lvt-source-wasm-test/`" assumption were both wrong; recorded the read-only reality + the TinyGo/provenance blocker inline (Goal note + Audit). Also incidental: `gofmt`-cleaned a pre-existing misalignment in `cli.go`'s `cliOptions` struct (the repo's CI does not enforce `gofmt`). +- **Feed-forward — the write-landing follow-up:** add a **reproducible writable wasm fixture** (a `write`-exporting module built by TinyGo, wired into `make`, never a committed mystery binary) + a test that lands a write over CLI *and* WS. Belongs to a TinyGo-available environment. This is the tracked residual for closing #216. Also unverified-and-left: the wasm doc's `tinkerdown wasm` SDK commands + `limits:`/`cache:` keys (existence not checked — a separate doc-accuracy pass, out of this phase). +- **New/changed risks:** #216 remains open with an explicit, honest residual rather than a false "verified" close — better than the alternative. The substrate-extensibility gap (#222/#249: registering a *new* source *type* at build time) is untouched and still deferred. #### Standing Audit item for milestones that bump `@livetemplate/client` (M2, M5) — artifact provenance @@ -1100,7 +1203,7 @@ The *what* + *why* of each is in § Roadmap; here is only the milestone's **kick | **M2** `Validate()` API | Go `livetemplate` | **Check `ClientVersion`** — it is a wire contract with no runtime handshake, so a server bump that moves it obliges matching the client (what M0 Phase 0 did: server v0.19.1 → client 0.18.2). A release touching only server-side APIs may leave `ClientVersion` unchanged — `Validate()` is arguably one — in which case this is legitimately a **No**. Read the constant, don't assume. | | **M3** approved-surface gate + field validation | **none** — tinkerdown-only (kickoff 2026-07-24 dropped the upstream `WithActionPolicy` as dead code) | **N/A.** No upstream bump, no client bump. | | **M4** component vocabulary | Go `lvt/components` | **No.** `github.com/livetemplate/lvt/components` is a server-side Go module consumed by `internal/server/websocket.go` and `internal/runtime/state.go`; it appears nowhere in `client/src` or `client/package.json`. Skip this checklist unless M4 also bumps `@livetemplate/client` for some client-side affordance a new component needs. | -| **M5** embed handshake | `client` | **Certainly** — it ships a client-side feature. | +| **M5** save & share (re-scoped 2026-07-24) | **none** — tinkerdown-only (embed deferred → #249) | **No.** The re-scoped M5 (capture-completeness + dogfooded gallery + #216 CLI parity) touches no `client` code. The old "embed handshake ships a client feature" row applied to the deferred scope only. | When the table says yes, the executing session must: - [ ] Re-read § Risks → *Committed-artifact provenance* and M0 Phase 0's Learn before touching `client/package.json` or `go.mod`. diff --git a/docs/sources/wasm.md b/docs/sources/wasm.md index 581f13e2..700a000b 100644 --- a/docs/sources/wasm.md +++ b/docs/sources/wasm.md @@ -8,7 +8,7 @@ Create custom data sources using WebAssembly. sources: custom: type: wasm - module: ./custom.wasm + path: ./custom.wasm ``` ## Options @@ -16,8 +16,27 @@ sources: | Option | Required | Description | |--------|----------|-------------| | `type` | Yes | Must be `wasm` | -| `module` | Yes | Path to WASM module | -| `config` | No | Configuration passed to module | +| `path` | Yes | Path to the `.wasm` module file | +| `options` | No | Init config passed to the module (a map of string values) | + +## Read-only vs. writable + +Writability is derived from the module itself, not the manifest: a module that +exports a `write` function (see *Required Interface* below) is **writable** — it +supports `add`, `update`, and `delete` — while a module that exports only `fetch` is +**read-only** (list only). A write against a read-only module is refused with a clear +error, never silently dropped. + +A writable WASM source has full parity across both entry points — the live server and +the CLI: + +```bash +# list (read) works for any WASM source +tinkerdown cli app.md list custom + +# add/update/delete work only if the module exports `write` +tinkerdown cli app.md add custom --title="New item" +``` ## Examples @@ -27,8 +46,8 @@ sources: sources: github_issues: type: wasm - module: ./sources/github.wasm - config: + path: ./sources/github.wasm + options: repo: livetemplate/tinkerdown ``` @@ -38,8 +57,8 @@ sources: sources: external_data: type: wasm - module: ./sources/external-api.wasm - config: + path: ./sources/external-api.wasm + options: api_key: ${EXTERNAL_API_KEY} resource_id: ${EXTERNAL_RESOURCE_ID} ``` @@ -168,7 +187,7 @@ WASM modules run with resource limits: sources: custom: type: wasm - module: ./custom.wasm + path: ./custom.wasm limits: memory: 64MB # Default: 64MB timeout: 30s # Default: 30s @@ -197,8 +216,8 @@ Browse community-contributed sources: sources: github_issues: type: wasm - module: ./sources/github.wasm - config: + path: ./sources/github.wasm + options: repo: livetemplate/tinkerdown token: ${GITHUB_TOKEN} cache: diff --git a/examples/gallery/README.md b/examples/gallery/README.md new file mode 100644 index 00000000..15416553 --- /dev/null +++ b/examples/gallery/README.md @@ -0,0 +1,24 @@ +# Saved skills gallery + +A dogfooded Tinkerdown app that lists the captured, re-runnable workflows under +`skills/` — the "save it, the need recurs" home for otherwise-ephemeral UIs. It is +itself a plain Tinkerdown app: an `lvt-source` over a CSV, no custom JavaScript. + +## Run it + +```bash +tinkerdown serve . +``` + +Open the served URL. Each row is a workflow captured with `/tinkerdown:save`; its +**Location** column points at the `skills//` dir whose `SKILL.md` carries the +stand-up steps. + +## How the listing stays current + +No source type reads a directory of `SKILL.md` frontmatter, so the gallery renders a +committed static index (`skills.csv`) rather than scanning `skills/` live (see +`docs/plans/2026-07-09-ephemeral-ui-reframe.md` § M5 Phase 2 for that STOP-gate +decision). `TestSavedSkillsGalleryInSync` asserts the index lists exactly the captured +workflows on disk — every `skills/` dir except the framework-authoring skills +`tinkerdown` and `tinkerdown-save` — so a new capture cannot silently go unlisted. diff --git a/examples/gallery/index.md b/examples/gallery/index.md new file mode 100644 index 00000000..5d04a970 --- /dev/null +++ b/examples/gallery/index.md @@ -0,0 +1,43 @@ +--- +title: "Saved skills gallery" +--- + +# Saved skills + +Ephemeral UIs are meant to be thrown away — generate one, use it, regenerate from an +up-to-date source of truth when the need returns. But a workflow worth keeping is +captured with `/tinkerdown:save` as a re-runnable skill — on-brand and with **no LLM +generation**. This gallery is the discoverable home for those saved workflows. + +```lvt +
+ {{if .Error}} +

Error: {{.Error}}

+ {{else}} + + + + + + + + + + + {{range .Data}} + + + + + + + {{end}} + +
WorkflowWhat it stands upSay / triggerLocation
{{.Name}}{{.Description}}{{.Triggers}}{{.Path}}
+ {{end}} +
+``` + +Each workflow's `SKILL.md` — under its **Location** — has the exact stand-up steps: +seed a fixture if the workflow needs pre-loaded rows, then `tinkerdown serve`. Re-running +a saved skill takes seconds because there is nothing to generate. diff --git a/examples/gallery/skills.csv b/examples/gallery/skills.csv new file mode 100644 index 00000000..28aecf86 --- /dev/null +++ b/examples/gallery/skills.csv @@ -0,0 +1,2 @@ +name,description,triggers,path +pii-access-approval,"Stand up the PII / data-export access-approval console in seconds — a governed request queue with bounded, audited Approve/Deny. A saved, re-runnable workflow; no LLM generation needed.","PII access approval; data-export approval; access request queue; approve access requests; sensitive data export console",skills/pii-access-approval diff --git a/examples/gallery/tinkerdown.yaml b/examples/gallery/tinkerdown.yaml new file mode 100644 index 00000000..d5017b3b --- /dev/null +++ b/examples/gallery/tinkerdown.yaml @@ -0,0 +1,17 @@ +# The saved-skills gallery — a discoverable home for captured Tinkerdown workflows. +# +# In-grain note (M5 Phase 2): no source type reads a directory of SKILL.md +# frontmatter, so the gallery renders a committed static index (skills.csv) rather +# than scanning skills/ live. TestSavedSkillsGalleryInSync keeps that index in step +# with the captured skills on disk. See docs/plans § M5 Phase 2 for the STOP-gate +# decision that led here. + +title: "Saved skills gallery" + +sources: + # A gallery lists; it never mutates — so the index is read-only. auto-generated + # editable controls stay off, and the page binds it purely for display. + skills: + type: csv + file: skills.csv + readonly: true diff --git a/saved_skills_gallery_e2e_test.go b/saved_skills_gallery_e2e_test.go new file mode 100644 index 00000000..84106dcd --- /dev/null +++ b/saved_skills_gallery_e2e_test.go @@ -0,0 +1,93 @@ +//go:build !ci + +package tinkerdown_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// TestSavedSkillsGalleryRenders serves the dogfooded saved-skills gallery and confirms +// a captured workflow renders as a browsable entry — its name, the "what it stands up" +// description, and a reachable location path — through the real lvt-source render path +// (no custom JS). This is the M5 Phase 2 acceptance: the captured skills have a +// discoverable home. +// +// Four-channel capture (CLAUDE.md): browser console + exceptions and WebSocket frames +// via listen(); server log via captureServerLog(); rendered HTML + screenshot via +// saveScreenshot(). All dumped on any failure. +func TestSavedSkillsGalleryRenders(t *testing.T) { + serverLog := captureServerLog(t) + + ts := serveConsole(t, "examples/gallery") + defer ts.Close() + + chromeCtx, cleanup := SetupDockerChrome(t, 60*time.Second) + defer cleanup() + ctx := chromeCtx.Context + + ch := &channels{serverLog: serverLog} + listen(ctx, ch) + + // Wait for the lvt-source container (present in SSR), then let the render path + // populate the rows — the same shape as the pii console E2E. The data-bearing + // arrives through the live render, not necessarily the initial HTML. + if err := chromedp.Run(ctx, + network.Enable(), + chromedp.Navigate(ConvertURLForDockerChrome(ts.URL)), + chromedp.WaitVisible(`[lvt-source="skills"]`, chromedp.ByQuery), + chromedp.Sleep(1500*time.Millisecond), + ); err != nil { + dumpGalleryHTML(t, ctx) + ch.dump(t) + t.Fatalf("navigate gallery: %v", err) + } + saveScreenshot(t, ctx, "saved-skills-gallery") + + var body string + if err := chromedp.Run(ctx, + chromedp.Evaluate(`document.body.innerText`, &body), + ); err != nil { + ch.dump(t) + t.Fatalf("read gallery text: %v", err) + } + + // The captured PII workflow renders with its name, a distinctive slice of its + // description, and its reachable location — the browsable entry the gallery exists + // to surface. + var missing []string + for _, want := range []string{ + "pii-access-approval", + "access-approval console", // distinctive slice of the description + "skills/pii-access-approval", // the reachable location + } { + if !strings.Contains(body, want) { + missing = append(missing, want) + } + } + if len(missing) > 0 { + // Dump the four-channel capture once, then report each miss. + dumpGalleryHTML(t, ctx) + ch.dump(t) + for _, want := range missing { + t.Errorf("gallery is missing %q; rendered body text:\n%s", want, body) + } + } +} + +// dumpGalleryHTML logs the rendered page HTML — the fourth capture channel — so a +// non-rendering source is diagnosable from the failure output alone. +func dumpGalleryHTML(t *testing.T, ctx context.Context) { + t.Helper() + var html string + if err := chromedp.Run(ctx, chromedp.OuterHTML(`html`, &html)); err != nil { + t.Logf("could not capture HTML: %v", err) + return + } + t.Logf("---- rendered HTML ----\n%s", html) +} diff --git a/saved_skills_gallery_test.go b/saved_skills_gallery_test.go new file mode 100644 index 00000000..5ce3fc1d --- /dev/null +++ b/saved_skills_gallery_test.go @@ -0,0 +1,92 @@ +package tinkerdown_test + +import ( + "encoding/csv" + "os" + "slices" + "testing" + + "github.com/livetemplate/tinkerdown/internal/config" +) + +// frameworkSkills are the skills/ dirs that are framework-authoring tooling, not +// captured user workflows. The saved-skills gallery indexes the latter. This set is +// explicit, not auto-detected: adding a new capture (or a new framework skill) forces +// an update here or in the gallery index — a deliberate choice, never silent drift. +// Same explicit-list philosophy as TestCapturedSkillsWellFormed (which names the +// skills it checks rather than sniffing for them). +var frameworkSkills = []string{"tinkerdown", "tinkerdown-save"} + +// TestSavedSkillsGalleryInSync asserts the committed gallery index +// (examples/gallery/skills.csv) lists exactly the captured-workflow skills present +// under skills/ — every skill dir that is not framework tooling. +// +// The gallery must render a static index because no source type reads a directory of +// SKILL.md frontmatter (the M5 Phase 2 STOP gate). This test is what keeps that static +// index honest as captures accumulate: a new skills// that is neither framework +// tooling nor listed fails here, forcing the author to list it (or classify it). +func TestSavedSkillsGalleryInSync(t *testing.T) { + // Captured workflows on disk = skills/ subdirs minus the framework set. + entries, err := os.ReadDir("skills") + if err != nil { + t.Fatalf("read skills/: %v", err) + } + var onDisk []string + for _, e := range entries { + if e.IsDir() && !slices.Contains(frameworkSkills, e.Name()) { + onDisk = append(onDisk, e.Name()) + } + } + + // Workflows the gallery lists = first column of the committed index (minus header). + f, err := os.Open("examples/gallery/skills.csv") + if err != nil { + t.Fatalf("open gallery index: %v", err) + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatalf("parse gallery index: %v", err) + } + if len(rows) < 1 { + t.Fatal("gallery index has no header row") + } + if got := rows[0][0]; got != "name" { + t.Fatalf("gallery index first column header is %q, want \"name\" (the app template reads {{.Name}})", got) + } + var listed []string + for _, r := range rows[1:] { + if len(r) > 0 { + listed = append(listed, r[0]) + } + } + + slices.Sort(onDisk) + slices.Sort(listed) + if !slices.Equal(onDisk, listed) { + t.Errorf("gallery index out of sync with skills/:\n"+ + " captured on disk: %v\n listed in gallery: %v\n"+ + "add the new capture to examples/gallery/skills.csv, or the new framework skill to frameworkSkills", + onDisk, listed) + } +} + +// TestSavedSkillsGalleryManifestLoads is the fast (non-Chrome) structural guard that +// the gallery is a real, loadable Tinkerdown app: its manifest loads and exposes the +// read-only csv source the page binds. The live render is verified by the E2E. +func TestSavedSkillsGalleryManifestLoads(t *testing.T) { + cfg, err := config.LoadFromDir("examples/gallery") + if err != nil { + t.Fatalf("load gallery manifest: %v", err) + } + src, ok := cfg.Sources["skills"] + if !ok { + t.Fatal("gallery manifest has no \"skills\" source") + } + if src.Type != "csv" { + t.Errorf("skills source type = %q, want csv", src.Type) + } + if !src.IsReadonly() { + t.Error("skills source must be readonly — a gallery lists, it never mutates") + } +} diff --git a/skills/tinkerdown-save/SKILL.md b/skills/tinkerdown-save/SKILL.md index 4094533c..01a3bca5 100644 --- a/skills/tinkerdown-save/SKILL.md +++ b/skills/tinkerdown-save/SKILL.md @@ -33,9 +33,15 @@ it does not fix a broken one. Look back over the conversation for the working UI the user produced and gather: 1. **The `app.md`** — the final, validated document that was served. -2. **The manifest** — the `tinkerdown.yaml` it ran against (its approved sources and - named actions), if there was one. A plain app with sources in frontmatter has no - separate manifest; note that. +2. **The manifest** — the `tinkerdown.yaml` it ran against, captured **whole**, not + trimmed to sources + actions. The manifest also carries the **house style** the app + rendered under: the `styling` block (theme + design `tokens`) and, if present, + `generation.style_guide` pointing at a `style-guide.md`. Keep all of it, so the + saved workflow re-runs **on-brand** — a manifest stripped back to sources + actions + re-runs on bare PicoCSS defaults, off-brand. The `style-guide.md` is a separate file + the yaml only *points* at, so it travels under the same **bundle-or-point** rule as + `seed.sql` / `app.md` (below). A plain app with sources in frontmatter has no + separate manifest — and so no `styling`/`style_guide` to carry; note that. 3. **The fixtures / data setup** — the schema + seed (a `seed.sql`, a `.db`, or the steps that created the data). Synthetic/sample data, not anything real. 4. **The stand-up steps** — how it was served: seeding the database, the @@ -89,8 +95,9 @@ breaks, not the markup. Get these three right: **Bundle vs. point — the rule that avoids duplication:** - **Bundle** the artifacts into `skills//` (`app.md`, `tinkerdown.yaml`, - `seed.sql`) when the UI was generated in a scratch/ephemeral directory that will be - thrown away — the skill is then their only home, so copies are not duplication. + `seed.sql`, and any `style-guide.md`) when the UI was generated in a + scratch/ephemeral directory that will be thrown away — the skill is then their only + home, so copies are not duplication. - **Point** at the existing path instead (do **not** copy) when the artifacts already live at a stable, committed location in the repo (e.g. under `examples/`). Copying a committed source of truth just creates two files that must stay identical — which is @@ -104,6 +111,9 @@ A captured skill nobody trusts is worse than none. Confirm: 2. Every path the `SKILL.md` references actually exists. 3. The stand-up steps are complete — a fresh reader could seed and serve from them alone, with nothing implicit. +4. The captured manifest carries the **house style** the app ran on — its `styling` + block travels, and any `style-guide.md` it references is bundled or pointed at — so + the saved workflow re-runs on-brand, not on defaults. If validation fails, the underlying app was not actually working — fix that first (or tell the user it is not ready to save), rather than capturing a broken artifact.