diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4d9925d..a2debc0 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,7 +30,7 @@ Usage: termchart lint [file] Check source is within the renderable subset termchart serve [flags] Start a local viewer + print its URL and env exports (--port --token --wsid) termchart begin [flags] Show a scope as "composing" instantly — placeholder + focus + loader in one call (--project --agent --description) - termchart push [flags] Push a diagram to the remote viewer (--project --agent --type --description --focus) + termchart push [flags] Push a diagram to the remote viewer (--project --agent --type --description --focus [--schema to make it template-able]) termchart status [flags] Push a status update — toast/progress/loader (--message --progress --loader --id --done) termchart clear [flags] Clear viewer scopes (--project --agent for one, or --all) termchart pull [flags] Fetch a stored view's spec to iterate on (--project --agent [--json]) @@ -39,7 +39,7 @@ Usage: termchart inbox [flags] Read the human→agent console for a scope (--project --agent [--since ] [--wait] [--follow] [--json]) --follow/-f streams messages as they arrive (resilient long-poll, like tail -f; Ctrl-C to stop) termchart suggest [flags] Push clickable suggestion chips to the human's console (--project --agent --items '[...]' / --item) - termchart template Reusable diagram templates: save --project --agent --name --hints | list | get | delete + termchart template Reusable diagram templates: save --project --agent --name | list | get | delete termchart --version Render flags: diff --git a/packages/cli/src/push.ts b/packages/cli/src/push.ts index b205451..925d737 100644 --- a/packages/cli/src/push.ts +++ b/packages/cli/src/push.ts @@ -8,7 +8,7 @@ export interface PushDeps { stdin?: string; } -interface Args { project?: string; agent?: string; type: string; description?: string; file?: string; focus: boolean; strict?: "error" | "warning"; error?: string; } +interface Args { project?: string; agent?: string; type: string; description?: string; file?: string; focus: boolean; strict?: "error" | "warning"; schemaFile?: string; error?: string; } function parse(argv: string[]): Args { const a: Args = { type: "mermaid", focus: false }; @@ -18,6 +18,9 @@ function parse(argv: string[]): Args { else if (arg === "--agent") a.agent = argv[++i]; else if (arg === "--type") a.type = argv[++i]; else if (arg === "--description") a.description = argv[++i]; + // Optional data schema (JSON: { summary?, fields:[{name,description,type?,example?}] }) that makes + // this board reusable as a template — captured when someone saves it. + else if (arg === "--schema") a.schemaFile = argv[++i]; else if (arg === "--focus") a.focus = true; // --strict (alone = error) / --strict=warn|error: reject the push when geometry findings meet the // threshold (server returns 422, nothing stored, CLI exits non-zero) so an agent loop can fix. @@ -73,13 +76,19 @@ export async function push(argv: string[], deps: PushDeps): Promise { const invalid = validateContent(a.type, content); if (invalid) { process.stderr.write(`push failed: ${invalid}\n`); return 1; } + let schema: unknown; + if (a.schemaFile) { + try { schema = JSON.parse(readFileSync(a.schemaFile, "utf8")); } + catch (e) { process.stderr.write(`cannot read --schema: ${(e as Error).message}\n`); return 3; } + } + const headers: Record = { "content-type": "application/json", authorization: `Bearer ${token}` }; if (a.strict) headers["x-termchart-strict"] = a.strict === "warning" ? "warn" : "error"; // opt-in reject-on-finding const post = (path: string, body: object) => fetch(`${base}/${path}`, { method: "POST", headers, body: JSON.stringify(body) }); try { - const r = await post("push", { project: a.project, agent: a.agent, type: a.type, description: a.description, content }); + const r = await post("push", { project: a.project, agent: a.agent, type: a.type, description: a.description, content, ...(schema !== undefined ? { schema } : {}) }); // Strict mode: the server rejected the push (422, nothing stored) because a geometry finding met // the --strict threshold. Print the findings and exit non-zero so an agent loop can fix + re-push. if (r.status === 422) { diff --git a/packages/cli/src/template.ts b/packages/cli/src/template.ts index 52a2775..8da857f 100644 --- a/packages/cli/src/template.ts +++ b/packages/cli/src/template.ts @@ -1,12 +1,13 @@ -import { readFileSync } from "node:fs"; import { EXIT_NO_VIEWER, isConnError, missingConfigMessage, unreachableMessage } from "./viewer-detect.js"; +import type { TemplateSchema } from "@ivanmkc/termchart-core"; -// `termchart template ` — reusable diagram templates. Save the structure of a -// board you like (decoupled from its data, with AI-facing `hints`) as a global template addressed by -// id, then in a FUTURE session `template get `, read the hints, author fresh data, and `push`. +// `termchart template ` — reusable diagram templates. Save a board's structure as +// a global template addressed by id; the data guidance comes from the `schema` the agent attached when +// it PUSHED the board (no note to fill in). In a FUTURE session, `template get ` prints the schema +// (data fields + descriptions) + an example; read it, author a conforming diagram, and `push`. // // All subcommands hit the wsid-scoped `${base}` (`…/w/`): `save` posts to `${base}/template` -// (reads that board's content), and `list`/`get`/`delete` use `${base}/templates` and +// (reads that board's content + schema), and `list`/`get`/`delete` use `${base}/templates` and // `${base}/template/`. The template LIBRARY is global (any workspace sees every template), but the // routes are workspace-scoped so the unguessable wsid gates access (the server rejects them otherwise). @@ -19,11 +20,12 @@ interface Template { name: string; type: string; content: string; - hints: string; + schema?: TemplateSchema; // agent-provided data guidance (current) + hints?: string; // legacy free-text note (older templates) description?: string; createdAt: number; } -type TemplateSummary = Omit; +type TemplateSummary = Omit; const ago = (ts: number, now: number): string => { const s = Math.max(0, Math.round((now - ts) / 1000)); @@ -59,7 +61,7 @@ export async function template(argv: string[], deps: TemplateDeps): Promise "")).trim(); process.stderr.write(`template save failed: HTTP ${r.status}${detail ? ` — ${detail}` : ""}\n`); return 1; } - const { id } = (await r.json()) as { id: string }; + const { id, hasSchema } = (await r.json()) as { id: string; hasSchema?: boolean }; process.stdout.write(`${id}\n`); - process.stderr.write(`saved template ${id} — reuse it later with: termchart template get ${id}\n`); + process.stderr.write(`saved template ${id}${hasSchema ? "" : " (no schema — push the board with --schema for reuse guidance)"} — reuse it with: termchart template get ${id}\n`); return 0; } @@ -132,11 +130,28 @@ async function getTemplate(argv: string[], base: string, headers: Record): Promise { const a = parseRead(argv); if (a.error) { process.stderr.write(a.error + "\n"); return 3; } diff --git a/packages/cli/test/template.test.ts b/packages/cli/test/template.test.ts index 34675b7..f50f572 100644 --- a/packages/cli/test/template.test.ts +++ b/packages/cli/test/template.test.ts @@ -34,15 +34,15 @@ const captureOut = () => { }; describe("template", () => { - it("save POSTs the board ref to /w//template and prints the new id", async () => { - const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id: "tpl_abc123" }))); + it("save POSTs only {project,agent,name} (no note) and prints the new id", async () => { + const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ id: "tpl_abc123", hasSchema: true }))); const cap = captureOut(); - const code = await template(["save", "--project", "p", "--agent", "a", "--name", "My table", "--hints", "WHAT: x"], { env: env(url) }); + const code = await template(["save", "--project", "p", "--agent", "a", "--name", "My table"], { env: env(url) }); const text = cap.done(); expect(code).toBe(0); expect(reqs[0].method).toBe("POST"); expect(reqs[0].path).toBe("/w/ws1/template"); - expect(reqs[0].body).toMatchObject({ project: "p", agent: "a", name: "My table", hints: "WHAT: x" }); + expect(reqs[0].body).toEqual({ project: "p", agent: "a", name: "My table" }); // no hints/schema in the request expect(text).toContain("tpl_abc123"); }); @@ -65,15 +65,26 @@ describe("template", () => { expect(text).toContain("Spend"); }); - it("get prints HINTS before the example content", async () => { - const tpl = { id: "tpl_1", name: "Spend", type: "datatable", content: '{"columns":["A"],"rows":[]}', hints: "ADAPT: swap rows", createdAt: Date.now() }; + it("get prints the SCHEMA (fields) before the example content", async () => { + const tpl = { id: "tpl_1", name: "Spend", type: "datatable", content: '{"columns":["A"],"rows":[]}', schema: { summary: "spend", fields: [{ name: "rows", type: "array", description: "swap rows each month" }] }, createdAt: Date.now() }; const { url, reqs } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(tpl))); const cap = captureOut(); const code = await template(["get", "tpl_1"], { env: env(url) }); const text = cap.done(); expect(code).toBe(0); expect(reqs[0].path).toBe("/w/ws1/template/tpl_1"); - expect(text.indexOf("ADAPT: swap rows")).toBeLessThan(text.indexOf('"columns"')); // hints first, then content + expect(text).toContain("DATA SCHEMA"); + expect(text).toContain("rows (array): swap rows each month"); + expect(text.indexOf("swap rows each month")).toBeLessThan(text.indexOf('"columns"')); // schema first, then content + }); + + it("get falls back to a legacy free-text note when there's no schema", async () => { + const tpl = { id: "tpl_1", name: "Old", type: "flow", content: "{}", hints: "WHAT: legacy", createdAt: Date.now() }; + const { url } = await routerStub((_r, res) => res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(tpl))); + const cap = captureOut(); + const code = await template(["get", "tpl_1"], { env: env(url) }); + expect(code).toBe(0); + expect(cap.done()).toContain("WHAT: legacy"); }); it("get --json prints the raw template object", async () => { diff --git a/packages/core/src/validate.ts b/packages/core/src/validate.ts index 9116849..0fbacd9 100644 --- a/packages/core/src/validate.ts +++ b/packages/core/src/validate.ts @@ -100,6 +100,42 @@ export function validateDataTable(v: unknown): string | null { return null; } +/** + * A board's optional DATA SCHEMA — what an agent attaches at push time so the board can later be saved + * as a reusable template. It documents which parts of the content are *data* (vs fixed structure) and + * how to supply new data, so a future agent can author a conforming diagram. Lightweight + display- + * friendly on purpose (not full JSON-Schema). + */ +export interface TemplateSchemaField { + name: string; // the data field (e.g. "rows", "data.values", "nodes[].label") + description: string; // what it is / how to fill it + type?: string; // optional human hint ("array", "number", "[service, region, usd]") + example?: string; // optional sample value (as a string) +} +export interface TemplateSchema { + summary?: string; // one line: what the diagram shows + fields: TemplateSchemaField[]; +} + +/** Validate an optional `schema` attached to a push. Absent → null (fine; schema is optional). Present + * → must be `{ fields: [{ name, description, type?, example? }] }` with non-empty name/description. */ +export function validateTemplateSchema(v: unknown): string | null { + if (v === undefined || v === null) return null; + if (!isObj(v)) return "schema must be an object { summary?, fields: [...] }"; + if (v.summary !== undefined && typeof v.summary !== "string") return "schema.summary must be a string"; + if (!Array.isArray(v.fields) || v.fields.length === 0) + return "schema.fields must be a non-empty array of { name, description }"; + for (let i = 0; i < v.fields.length; i++) { + const f = v.fields[i]; + if (!isObj(f)) return `schema.fields[${i}] must be an object`; + if (typeof f.name !== "string" || f.name.length === 0) return `schema.fields[${i}].name must be a non-empty string`; + if (typeof f.description !== "string" || f.description.length === 0) return `schema.fields[${i}].description must be a non-empty string`; + if (f.type !== undefined && typeof f.type !== "string") return `schema.fields[${i}].type must be a string`; + if (f.example !== undefined && typeof f.example !== "string") return `schema.fields[${i}].example must be a string`; + } + return null; +} + /** Validate `content` for a given push `type`. Returns an error message or null (valid / freeform). * `depth` bounds nested-panes recursion (a pane's content can itself be a `panes` payload). */ export function validateContent(type: string, content: string, depth = 0): string | null { diff --git a/packages/viewer/e2e/template.e2e.mjs b/packages/viewer/e2e/template.e2e.mjs index 5cdb153..f65a649 100644 --- a/packages/viewer/e2e/template.e2e.mjs +++ b/packages/viewer/e2e/template.e2e.mjs @@ -24,13 +24,14 @@ const results = []; const ok = (n, c) => { results.push(c); console.log(`${c ? "PASS" : "FAIL"} ${n}`); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -async function push(agent, type, content) { +async function push(agent, type, content, schema) { const r = await fetch(`${BASE}/w/${WSID}/push`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, - body: JSON.stringify({ project: "p", agent, type, content, description: `e2e ${agent}` }), + body: JSON.stringify({ project: "p", agent, type, content, description: `e2e ${agent}`, ...(schema ? { schema } : {}) }), }); if (!r.ok) throw new Error(`push ${agent} -> ${r.status}`); } +const SCHEMA = { summary: "monthly cloud spend by service", fields: [{ name: "rows", type: "array", description: "one row per service: [service, region, usd, delta, owner]" }] }; async function waitForServer(t = 15000) { const end = Date.now() + t; while (Date.now() < end) { try { if ((await fetch(`${BASE}/healthz`)).ok) return; } catch { /* */ } await sleep(200); } @@ -44,7 +45,7 @@ const srv = spawn("node", [join(VIEWER, "dist", "server.js")], { let browser; try { await waitForServer(); - await push("spend", "datatable", readFileSync(join(EX, "cost-table.datatable.json"), "utf8")); + await push("spend", "datatable", readFileSync(join(EX, "cost-table.datatable.json"), "utf8"), SCHEMA); browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] }); const p = await browser.newPage({ viewport: { width: 1200, height: 850 } }); @@ -54,30 +55,31 @@ try { await p.goto(`${BASE}/w/${WSID}/#p/spend`, { waitUntil: "domcontentloaded", timeout: 20000 }); await p.waitForFunction(() => document.querySelectorAll("#diagram .tc-datatable tbody tr").length >= 3, null, { timeout: 15000 }); - // Save as template via the UI (the note is a plain, optional free-text field — no jargon scaffold). + // Save as template via the UI — NO note field; the guidance is the schema the agent pushed. await p.locator("#templates").click(); await p.waitForSelector(".templates-popover .tpl-save", { timeout: 5000 }); - ok("UI: note field is empty by default (no jargon scaffold)", (await p.locator(".tpl-note").inputValue()) === ""); + ok("UI: no note/textarea field to fill in", (await p.locator(".templates-popover textarea").count()) === 0); + ok("UI: shows the board's captured schema status", /schema captured/i.test(await p.locator(".tpl-schema-status").textContent())); await p.fill(".tpl-name", "Cloud spend table"); - await p.fill(".tpl-note", "Monthly spend by service — swap the rows each month, keep the columns."); await p.locator(".tpl-save-btn").click(); - // Success surfaces a copyable id + a "give your agent this id" instruction. - await p.waitForFunction(() => { const el = document.querySelector(".tpl-result"); return el && !el.hidden && /^tpl_/.test(el.querySelector(".tpl-id")?.textContent || ""); }, null, { timeout: 8000 }); - ok("UI: save shows the id + how to reuse it", /give your agent this id/i.test(await p.locator(".tpl-result").textContent())); + // Success surfaces a ready-to-paste reuse prompt (incl. the id + the get command). + await p.waitForFunction(() => { const el = document.querySelector(".tpl-result"); return el && !el.hidden && /Reuse termchart template tpl_/.test(el.querySelector(".tpl-id")?.textContent || ""); }, null, { timeout: 8000 }); + ok("UI: save shows a paste-able reuse prompt", /paste this to an agent/i.test(await p.locator(".tpl-result").textContent())); await p.waitForFunction(() => document.querySelectorAll(".templates-popover .tpl-row").length === 1, null, { timeout: 5000 }); ok("UI: saved template appears in the library list", (await p.locator(".templates-popover .tpl-row").count()) === 1); + ok("UI: library row offers a 'Copy reuse prompt' affordance", (await p.locator(".tpl-row .tpl-act", { hasText: "Copy reuse prompt" }).count()) === 1); - // Inline note expand. - await p.locator(".tpl-row .tpl-act", { hasText: "Note" }).first().click(); - await p.waitForFunction(() => /swap the rows/.test(document.querySelector(".tpl-hints-box")?.textContent || ""), null, { timeout: 5000 }); - ok("UI: note expands inline", /keep the columns/.test(await p.locator(".tpl-hints-box").first().textContent())); + // Inline schema expand. + await p.locator(".tpl-row .tpl-act", { hasText: "Schema" }).first().click(); + await p.waitForFunction(() => /one row per service/.test(document.querySelector(".tpl-hints-box")?.textContent || ""), null, { timeout: 5000 }); + ok("UI: schema expands inline (shows the data fields)", /rows/.test(await p.locator(".tpl-hints-box").first().textContent())); - // Global HTTP API: list + get (hints + content). + // Global HTTP API: list + get (schema + content). const list = await (await fetch(`${BASE}/w/${WSID}/templates`)).json(); ok("GET /templates lists the template", Array.isArray(list) && list.length === 1 && list[0].type === "datatable"); const id = list[0].id; const full = await (await fetch(`${BASE}/w/${WSID}/template/${id}`)).json(); - ok("GET /template/ returns the note + example content", full.hints.includes("swap the rows") && full.content.length > 10 && full.type === "datatable"); + ok("GET /template/ returns the schema + example content", full.schema?.fields?.[0]?.name === "rows" && full.content.length > 10 && full.type === "datatable"); ok("GET /template/ is 404", (await fetch(`${BASE}/w/${WSID}/template/tpl_missing`)).status === 404); // Reuse: adapt the example (swap a row) and push it to a NEW scope — proves the loop. diff --git a/packages/viewer/src/client/style.css b/packages/viewer/src/client/style.css index f128969..6d65b17 100644 --- a/packages/viewer/src/client/style.css +++ b/packages/viewer/src/client/style.css @@ -207,8 +207,9 @@ header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; borde .tpl-intro strong { color: var(--fg); } .tpl-field { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; color: var(--muted); } .tpl-opt { color: var(--muted); font-weight: 400; } -.tpl-name, .tpl-note { width: 100%; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; } -.tpl-note { resize: vertical; line-height: 1.4; } +.tpl-name { width: 100%; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box; } +.tpl-schema-status { font-size: 11.5px; line-height: 1.4; color: var(--muted); } +.tpl-schema-status.ok { color: var(--live, #22c55e); } .tpl-save-row { display: flex; align-items: center; gap: 8px; justify-content: flex-end; } .tpl-save-msg { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tpl-result { display: flex; flex-direction: column; gap: 6px; padding: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; } @@ -216,7 +217,8 @@ header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; borde .tpl-id-row { display: flex; align-items: center; gap: 8px; } .tpl-id { flex: 1; min-width: 0; padding: 3px 6px; font-family: var(--font-mono); font-size: 12px; color: var(--accent); background: var(--bg); border: 1px solid var(--border); border-radius: 5px; overflow: hidden; text-overflow: ellipsis; } .tpl-divider { height: 1px; background: var(--border); } -.tpl-list-head { font-size: 12.5px; font-weight: 600; color: var(--muted); } +.tpl-list-head { font-size: 12.5px; font-weight: 600; color: var(--fg); } +.tpl-list-sub { display: block; font-weight: 400; font-size: 11px; color: var(--muted); margin-top: 2px; } .tpl-list { display: flex; flex-direction: column; gap: 6px; } .tpl-empty { font-size: 12px; color: var(--muted); } .tpl-row { display: flex; flex-direction: column; gap: 4px; padding: 7px 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; } diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index 48172aa..f87d4e9 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -4,9 +4,9 @@ import { INTRO_PANES } from "./intro.js"; import { setActiveScope } from "./interact.js"; import { initConsole, setConsoleScope, onInboxEvent, onInboxRead, onSuggest, resyncConsole } from "./console.js"; import type { PatchOp } from "../flow-patch.js"; -import { sortBoards, type BoardSort } from "../state.js"; +import { sortBoards, type BoardSort, type TemplateSchema } from "../state.js"; -interface Payload { project: string; agent: string; type: string; content: string; ts: number; createdAt?: number; } +interface Payload { project: string; agent: string; type: string; content: string; ts: number; createdAt?: number; schema?: TemplateSchema; } const key = (p: { project: string; agent: string }) => `${p.project}/${p.agent}`; // Two surfaces share this bundle: the normal workspace `/w//` and a single-board SHARE view @@ -808,16 +808,25 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { // Save section — only when a real board is on screen (the intro/welcome has nothing to save). const onBoard = !!current && current !== INTRO && !!scopes.get(current); if (onBoard) { + const boardSchema = scopes.get(current)?.schema; const save = document.createElement("div"); save.className = "tpl-save"; save.innerHTML = - '
Save this board’s design — its layout and styling, not the data — so you or an agent can rebuild it later with new data.
' + + '
Save this board’s design — its layout and styling, not the data — so an agent can rebuild it later with new data.
' + '' + - '' + + '
' + '
' + ''; (save.querySelector(".tpl-name") as HTMLInputElement).value = current; + // No note to fill in — the reuse guidance is the data schema the agent attached when it pushed the + // board. Show whether one is present; nothing for the human to author. + const status = save.querySelector(".tpl-schema-status") as HTMLElement; + if (boardSchema && Array.isArray(boardSchema.fields) && boardSchema.fields.length) { + status.classList.add("ok"); + status.textContent = `✓ Data schema captured (${boardSchema.fields.length} field${boardSchema.fields.length === 1 ? "" : "s"}) — an agent can reuse this.`; + } else { + status.textContent = "No data schema on this board — an agent will infer the data from the example. (Push with --schema to add one.)"; + } const msg = save.querySelector(".tpl-save-msg") as HTMLElement; const result = save.querySelector(".tpl-result") as HTMLElement; const saveBtn = save.querySelector(".tpl-save-btn") as HTMLButtonElement; @@ -825,29 +834,29 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { const sl = current.indexOf("/"); const project = current.slice(0, sl), agent = current.slice(sl + 1); const name = (save.querySelector(".tpl-name") as HTMLInputElement).value.trim() || current; - const hints = (save.querySelector(".tpl-note") as HTMLTextAreaElement).value.trim(); saveBtn.disabled = true; msg.textContent = "Saving…"; result.hidden = true; try { const r = await fetch(`${apiBase}/template`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ project, agent, name, hints }), + body: JSON.stringify({ project, agent, name }), }); if (!r.ok) { msg.textContent = `Save failed (HTTP ${r.status})`; saveBtn.disabled = false; return; } - const d = (await r.json()) as { id: string }; + const d = (await r.json()) as { id: string; type?: string }; msg.textContent = ""; - // Tell the human what to DO with it: copy the id, hand it to an agent. - await copyText(d.id); + // Tell the human exactly what to DO with it: copy a ready-to-paste instruction for an agent. + const prompt = reusePrompt(d.id, d.type || ""); + await copyText(prompt); result.replaceChildren(); const line = document.createElement("div"); line.className = "tpl-result-line"; - line.textContent = "✓ Saved. To reuse it, give your agent this id:"; - const row = document.createElement("div"); - row.className = "tpl-id-row"; - const code = document.createElement("code"); code.className = "tpl-id"; code.textContent = d.id; + line.textContent = `✓ Saved as ${d.id}. To reuse it, paste this to an agent (copied):`; + const box = document.createElement("div"); + box.className = "tpl-id-row"; + const code = document.createElement("code"); code.className = "tpl-id"; code.textContent = prompt; const copy = document.createElement("button"); copy.type = "button"; copy.className = "tpl-act"; copy.textContent = "Copied ✓"; - copy.onclick = async () => { const ok = await copyText(d.id); copy.textContent = ok ? "Copied ✓" : "Copy id"; }; - row.append(code, copy); - result.append(line, row); + copy.onclick = async () => { const ok = await copyText(prompt); copy.textContent = ok ? "Copied ✓" : "Copy"; }; + box.append(code, copy); + result.append(line, box); result.hidden = false; await renderTemplateList(listWrap); } catch (e) { @@ -860,7 +869,7 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { const listHead = document.createElement("div"); listHead.className = "tpl-list-head"; - listHead.textContent = "Saved templates"; + listHead.innerHTML = 'Saved templatesyour library — paste a template’s reuse prompt to an agent to rebuild it with new data'; pop.appendChild(listHead); const listWrap = document.createElement("div"); listWrap.className = "tpl-list"; @@ -899,12 +908,12 @@ async function renderTemplateList(wrap: HTMLElement): Promise { const acts = document.createElement("div"); acts.className = "tpl-row-acts"; const copyId = document.createElement("button"); - copyId.type = "button"; copyId.className = "tpl-act"; copyId.textContent = "Copy id"; - copyId.title = `Copy ${t.id} — give it to an agent to reuse this template`; - copyId.onclick = async () => { const ok = await copyText(t.id); copyId.textContent = ok ? "Copied ✓" : "Failed"; setTimeout(() => { copyId.textContent = "Copy id"; }, 1200); }; + copyId.type = "button"; copyId.className = "tpl-act"; copyId.textContent = "Copy reuse prompt"; + copyId.title = `Copy a ready-to-paste instruction for an agent (incl. id ${t.id})`; + copyId.onclick = async () => { const ok = await copyText(reusePrompt(t.id, t.type)); copyId.textContent = ok ? "Copied ✓" : "Failed"; setTimeout(() => { copyId.textContent = "Copy reuse prompt"; }, 1400); }; const hintsBtn = document.createElement("button"); - hintsBtn.type = "button"; hintsBtn.className = "tpl-act"; hintsBtn.textContent = "Note"; - hintsBtn.title = "Show the reuse note"; + hintsBtn.type = "button"; hintsBtn.className = "tpl-act"; hintsBtn.textContent = "Schema"; + hintsBtn.title = "Show the data schema an agent needs to reuse this"; const del = document.createElement("button"); del.type = "button"; del.className = "tpl-act tpl-act-danger"; del.textContent = "Delete"; del.onclick = async () => { @@ -915,7 +924,8 @@ async function renderTemplateList(wrap: HTMLElement): Promise { }; acts.append(copyId, hintsBtn, del); row.appendChild(acts); - // Inline hints panel (lazy-fetched on first expand). + // Inline schema panel (lazy-fetched on first expand): the agent-provided data fields, or a legacy + // free-text note, or a fallback. const hintsBox = document.createElement("pre"); hintsBox.className = "tpl-hints-box"; hintsBox.hidden = true; hintsBtn.onclick = async () => { @@ -924,9 +934,9 @@ async function renderTemplateList(wrap: HTMLElement): Promise { hintsBox.textContent = "Loading…"; try { const r = await fetch(`${apiBase}/template/${encodeURIComponent(t.id)}`); - const full = r.ok ? (await r.json()) as { hints?: string } : null; - hintsBox.textContent = (full?.hints || "").trim() || "(no note)"; - } catch { hintsBox.textContent = "(failed to load hints)"; } + const full = r.ok ? (await r.json()) as { schema?: TemplateSchema; hints?: string } : null; + hintsBox.textContent = formatTemplateSchema(full?.schema, full?.hints); + } catch { hintsBox.textContent = "(failed to load schema)"; } hintsBox.dataset.loaded = "1"; } hintsBox.hidden = false; @@ -936,6 +946,30 @@ async function renderTemplateList(wrap: HTMLElement): Promise { } } +/** A ready-to-paste instruction telling an agent how to reuse a template by id. The agent (with the + * diagram-recipes skill) fetches the schema + example, authors a conforming diagram, and pushes. */ +function reusePrompt(id: string, type?: string): string { + const t = type ? ` (a ${type})` : ""; + return `Reuse termchart template ${id}${t}: run \`termchart template get ${id}\` to see its data schema + example, then build a new diagram with my data that fits the schema and push it.`; +} + +/** Pretty-print a template's reuse guidance: the structured data schema if present, else a legacy + * free-text note, else a fallback. */ +function formatTemplateSchema(schema?: TemplateSchema, legacyHints?: string): string { + if (schema && Array.isArray(schema.fields) && schema.fields.length) { + const lines: string[] = []; + if (schema.summary) { lines.push(schema.summary, ""); } + for (const f of schema.fields) { + const ty = f.type ? ` (${f.type})` : ""; + const ex = f.example ? ` e.g. ${f.example}` : ""; + lines.push(`• ${f.name}${ty}: ${f.description}${ex}`); + } + return lines.join("\n"); + } + if (legacyHints && legacyHints.trim()) return legacyHints.trim(); + return "(no schema — an agent infers the data from the example)"; +} + // --- Board history (rewind & inspect) ------------------------------------------------------------ let historyEnabled = false; let historyPanel: HTMLElement | null = null; diff --git a/packages/viewer/src/server.ts b/packages/viewer/src/server.ts index 2dd1c4e..2d4495d 100644 --- a/packages/viewer/src/server.ts +++ b/packages/viewer/src/server.ts @@ -9,7 +9,7 @@ import { ShareStore, SHARES_WSID } from "./share-store.js"; import { TemplateStore, TEMPLATES_WSID } from "./template-store.js"; import { ShareHub } from "./share-hub.js"; import { HistoryStore } from "./history-store.js"; -import { validateContent } from "@ivanmkc/termchart-core"; +import { validateContent, validateTemplateSchema, type TemplateSchema } from "@ivanmkc/termchart-core"; import { geometryWarnings, geometryReport, maxSeverity, type Severity } from "./flow-geometry.js"; import { applyFlowPatch, type PatchOp } from "./flow-patch.js"; import { applyComponentPatch, applyInteract, InteractError, type ComponentPatchOp } from "./component-patch.js"; @@ -113,6 +113,9 @@ function asPayload(body: unknown): Payload | null { content: b.content as string, description: b.description as string, ts: Date.now(), + // Optional agent-provided data schema (validated by the push handler). Carried through so a later + // "save as template" captures it automatically — no human note required. + ...(b.schema !== undefined ? { schema: b.schema as TemplateSchema } : {}), }; } @@ -496,21 +499,21 @@ export function createViewerServer(opts: ServerOpts) { // Save the named board as a reusable, global template — wsid-gated (the unguessable workspace id is // the capability, same as clear/share; the viewer UI holds no push token) and demo-blocked by the - // read-only POST guard above. Reads the board's stored content/type and mints a reusable id. + // read-only POST guard above. Captures the board's content/type/description AND its agent-provided + // `schema` (the structured data guidance attached at push time) — no human note is requested. if (req.method === "POST" && action === "template") { const body = await readJson(req).catch(() => null); const b = (typeof body === "object" && body ? body : {}) as Record; if (typeof b.project !== "string" || typeof b.agent !== "string" || !b.project || !b.agent) - return send(res, 400, "template needs { project, agent, name?, hints? }"); + return send(res, 400, "template needs { project, agent, name? }"); await hydratedForRead(wsid); const board = store.list(wsid).find((p) => p.project === b.project && p.agent === b.agent); if (!board) return send(res, 404, "no such board to save as a template"); const name = typeof b.name === "string" && b.name ? b.name : `${b.project}/${b.agent}`; - const hints = typeof b.hints === "string" ? b.hints : ""; if (!(await hydratedForWrite(TEMPLATES_WSID))) return send(res, 503, "state backend unavailable; refusing to modify to avoid data loss"); - const tpl = templateStore.create(name, board.type, board.content, hints, board.description); + const tpl = templateStore.create(name, board.type, board.content, board.schema, board.description); await persistSave(TEMPLATES_WSID); - return send(res, 200, JSON.stringify({ id: tpl.id, name: tpl.name, type: tpl.type }), "application/json"); + return send(res, 200, JSON.stringify({ id: tpl.id, name: tpl.name, type: tpl.type, hasSchema: !!tpl.schema }), "application/json"); } // Revoke a share. 404 (not 403) on a foreign/unknown token so a caller can't probe other wsids. @@ -645,6 +648,8 @@ export function createViewerServer(opts: ServerOpts) { if (!p) return send(res, 400, "invalid payload — project, agent, type, content, description are all required"); const invalid = validateContent(p.type, p.content); if (invalid) return send(res, 400, invalid); // signal the precise reason to the pusher + const badSchema = validateTemplateSchema(p.schema); // optional; rejects only if malformed + if (badSchema) return send(res, 400, badSchema); // Deterministic readability lint (edges over nodes / crossings / overlaps), computed BEFORE // the store so opt-in strict mode can reject without persisting. error = likely unreadable, // warning = suboptimal. `x-termchart-strict: error|warn` → reject (422, not stored) at/above diff --git a/packages/viewer/src/state.ts b/packages/viewer/src/state.ts index 3399833..999ec8c 100644 --- a/packages/viewer/src/state.ts +++ b/packages/viewer/src/state.ts @@ -1,3 +1,6 @@ +import type { TemplateSchema } from "@ivanmkc/termchart-core"; +export type { TemplateSchema } from "@ivanmkc/termchart-core"; + export interface Payload { project: string; agent: string; @@ -6,6 +9,7 @@ export interface Payload { description: string; // short human/agent-readable summary, shown in the catalog (GET /list) ts: number; // last-update time (updated_at) — bumped on every push/patch/interact/restore createdAt?: number; // first-push time (created_at); set once by Store.set and preserved across updates + schema?: TemplateSchema; // optional agent-provided data schema (makes the board template-able) } export const scopeKey = (project: string, agent: string): string => diff --git a/packages/viewer/src/template-store.ts b/packages/viewer/src/template-store.ts index dff4bbc..c437814 100644 --- a/packages/viewer/src/template-store.ts +++ b/packages/viewer/src/template-store.ts @@ -1,12 +1,13 @@ import { randomBytes } from "node:crypto"; -import type { Store, Payload } from "./state.js"; +import type { Store, Payload, TemplateSchema } from "./state.js"; /** - * Reusable diagram templates. A template decouples a diagram's STRUCTURE/STYLING (kept as a concrete, - * working example) from the DATA it was built from, plus a natural-language `hints` block that tells a - * future AI what the template is and how to adapt it for new data. Reuse is AI-driven: a later session - * does `template get `, reads the hints, authors fresh data, and pushes a normal diagram — there is - * no deterministic server-side "apply". + * Reusable diagram templates. A template keeps a diagram's STRUCTURE/STYLING (a concrete, working + * example) plus the agent-provided `schema` attached at push time — the data fields + descriptions that + * tell a future agent how to supply new data. Reuse is agent-driven: a later session does + * `template get `, reads the structure + schema, and authors a conforming diagram — there is no + * deterministic server-side "apply". (Legacy templates carry a free-text `hints` note instead of a + * `schema`; both are read here for back-compat.) * * Persistence with zero backend-interface change (same trick as ShareStore): each template is a * `Payload` row under a RESERVED, never-servable workspace `__templates__`, so it rides the server's @@ -22,18 +23,20 @@ export interface Template { name: string; type: string; // the diagram content type (flow/datatable/component/vegalite/panes/markdown/…) content: string; // the example diagram content (a JSON string), a concrete working diagram - hints: string; // freeform, AI-facing guidance: what it is, which parts are data, how to adapt + schema?: TemplateSchema; // agent-provided data schema (fields + descriptions) — the reuse guidance + hints?: string; // legacy free-text note (older templates, pre-schema); still surfaced for back-compat description?: string; // the source board's description, if any createdAt: number; } -export type TemplateSummary = Omit; +export type TemplateSummary = Omit; interface TemplateBody { name: string; type: string; content: string; - hints: string; + schema?: TemplateSchema; + hints?: string; description?: string; createdAt: number; } @@ -61,17 +64,19 @@ export class TemplateStore { name: typeof b.name === "string" ? b.name : p.description, type: b.type, content: b.content, - hints: typeof b.hints === "string" ? b.hints : "", + schema: b.schema && typeof b.schema === "object" && !Array.isArray(b.schema) ? (b.schema as TemplateSchema) : undefined, + hints: typeof b.hints === "string" && b.hints ? b.hints : undefined, // legacy description: typeof b.description === "string" ? b.description : undefined, createdAt: typeof b.createdAt === "number" ? b.createdAt : p.ts, }; } - /** Mint a new template and store it. Returns the created template (with its fresh id). */ - create(name: string, type: string, content: string, hints: string, description?: string): Template { + /** Mint a new template and store it. Returns the created template (with its fresh id). `schema` is + * the agent-provided data guidance captured from the source board (undefined for plain boards). */ + create(name: string, type: string, content: string, schema?: TemplateSchema, description?: string): Template { const id = `tpl_${randomBytes(9).toString("base64url")}`; // 12 url-safe chars, no padding const createdAt = Date.now(); - const body: TemplateBody = { name, type, content, hints, description, createdAt }; + const body: TemplateBody = { name, type, content, schema, description, createdAt }; const rec: Payload = { project: id, agent: "_", @@ -81,7 +86,7 @@ export class TemplateStore { ts: createdAt, }; this.store.set(TEMPLATES_WSID, rec); - return { id, name, type, content, hints, description, createdAt }; + return { id, name, type, content, schema, description, createdAt }; } get(id: string): Template | null { @@ -91,7 +96,7 @@ export class TemplateStore { return null; } - /** Lightweight catalog (no content/hints), newest first. */ + /** Lightweight catalog (no content/schema/hints), newest first. */ list(): TemplateSummary[] { const out: TemplateSummary[] = []; for (const p of this.records()) { diff --git a/packages/viewer/test/server.test.ts b/packages/viewer/test/server.test.ts index 52fcd93..4aba314 100644 --- a/packages/viewer/test/server.test.ts +++ b/packages/viewer/test/server.test.ts @@ -913,30 +913,45 @@ describe("read-only demo workspace (seeded showcase)", () => { }); describe("template library", () => { + const SCHEMA = { summary: "spend by service", fields: [{ name: "rows", type: "array", description: "one row per service" }] }; const saveTpl = (body: object) => fetch(`${base}/w/ws1/template`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); - const seedBoard = () => push({ project: "p", agent: "a", type: "markdown", content: "# hi", description: "d" }); + const seedBoard = (extra: object = {}) => push({ project: "p", agent: "a", type: "markdown", content: "# hi", description: "d", ...extra }); - it("saves a board as a template, lists it, fetches it, and deletes it (wsid-scoped)", async () => { - await seedBoard(); - const created = await saveTpl({ project: "p", agent: "a", name: "My tpl", hints: "WHAT: x" }); + it("captures the board's pushed schema (not a human note), lists, fetches, deletes (wsid-scoped)", async () => { + await seedBoard({ schema: SCHEMA }); // schema attached at PUSH time + const created = await saveTpl({ project: "p", agent: "a", name: "My tpl" }); // no hints/schema in the save request expect(created.status).toBe(200); - const { id } = await created.json(); - expect(id).toMatch(/^tpl_/); + const body = await created.json(); + expect(body).toMatchObject({ id: expect.stringMatching(/^tpl_/), hasSchema: true }); + const { id } = body; const list = await fetch(`${base}/w/ws1/templates`).then((r) => r.json()); expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ id, name: "My tpl", type: "markdown" }); expect(list[0]).not.toHaveProperty("content"); // summary only + expect(list[0]).not.toHaveProperty("schema"); const got = await fetch(`${base}/w/ws1/template/${id}`).then((r) => r.json()); - expect(got).toMatchObject({ id, content: "# hi", hints: "WHAT: x", type: "markdown" }); + expect(got).toMatchObject({ id, content: "# hi", type: "markdown", schema: SCHEMA }); expect((await fetch(`${base}/w/ws1/template/${id}`, { method: "DELETE" })).status).toBe(204); expect(await fetch(`${base}/w/ws1/templates`).then((r) => r.json())).toHaveLength(0); }); + it("saves a board pushed WITHOUT a schema (schema optional) → hasSchema false", async () => { + await seedBoard(); + const r = await saveTpl({ project: "p", agent: "a", name: "n" }); + expect(r.status).toBe(200); + expect((await r.json()).hasSchema).toBe(false); + }); + + it("rejects a push with a MALFORMED schema (400)", async () => { + const r = await push({ project: "p", agent: "bad", type: "markdown", content: "# x", description: "d", schema: { fields: [{ name: "" }] } }); + expect(r.status).toBe(400); + }); + it("rejects saving a nonexistent board (404) and missing fields (400)", async () => { expect((await saveTpl({ project: "nope", agent: "nope", name: "x" })).status).toBe(404); expect((await saveTpl({})).status).toBe(400); @@ -949,7 +964,7 @@ describe("template library", () => { it("is wsid-gated, not origin-rooted (the open routes are gone)", async () => { await seedBoard(); - const { id } = await (await saveTpl({ project: "p", agent: "a", name: "n", hints: "h" })).json(); + const { id } = await (await saveTpl({ project: "p", agent: "a", name: "n" })).json(); // No anonymous, workspace-less catalog or delete. expect((await fetch(`${base}/templates`)).status).toBe(404); expect((await fetch(`${base}/template/${id}`, { method: "DELETE" })).status).not.toBe(204); diff --git a/packages/viewer/test/template-store.test.ts b/packages/viewer/test/template-store.test.ts index 9790f70..9d59849 100644 --- a/packages/viewer/test/template-store.test.ts +++ b/packages/viewer/test/template-store.test.ts @@ -1,29 +1,36 @@ import { describe, expect, it } from "vitest"; import { Store } from "../src/state.js"; import { TemplateStore, TEMPLATES_WSID } from "../src/template-store.js"; +import type { TemplateSchema } from "@ivanmkc/termchart-core"; + +const SCHEMA: TemplateSchema = { + summary: "monthly cloud spend by service", + fields: [{ name: "rows", type: "array", description: "one row per service: [service, monthly_usd]" }], +}; describe("TemplateStore", () => { - it("creates a template with a tpl_ id and resolves it by id", () => { + it("creates a template with a tpl_ id and captures the schema", () => { const ts = new TemplateStore(new Store()); - const t = ts.create("Cloud spend", "datatable", '{"columns":["A"],"rows":[]}', "WHAT: spend", "desc"); + const t = ts.create("Cloud spend", "datatable", '{"columns":["A"],"rows":[]}', SCHEMA, "desc"); expect(t.id).toMatch(/^tpl_[A-Za-z0-9_-]+$/); const got = ts.get(t.id); expect(got).not.toBeNull(); expect(got!.name).toBe("Cloud spend"); expect(got!.type).toBe("datatable"); expect(got!.content).toBe('{"columns":["A"],"rows":[]}'); - expect(got!.hints).toBe("WHAT: spend"); + expect(got!.schema).toEqual(SCHEMA); expect(got!.description).toBe("desc"); }); - it("list() returns summaries (no content/hints); remove() deletes", () => { + it("saves fine without a schema (plain board); list() summaries omit content/schema", () => { const ts = new TemplateStore(new Store()); - const a = ts.create("A", "flow", "{}", "h", undefined); - const b = ts.create("B", "markdown", "# b", "h2", undefined); + const a = ts.create("A", "flow", "{}"); + const b = ts.create("B", "markdown", "# b"); + expect(ts.get(a.id)!.schema).toBeUndefined(); const list = ts.list(); expect(list.map((t) => t.id).sort()).toEqual([a.id, b.id].sort()); expect(list[0]).not.toHaveProperty("content"); - expect(list[0]).not.toHaveProperty("hints"); + expect(list[0]).not.toHaveProperty("schema"); expect(ts.remove(a.id)).toBe(true); expect(ts.get(a.id)).toBeNull(); expect(ts.list()).toHaveLength(1); @@ -32,9 +39,7 @@ describe("TemplateStore", () => { it("list() sorts newest-first by createdAt", () => { const ts = new TemplateStore(new Store()); - const older = ts.create("old", "flow", "{}", "h"); - // Force a later createdAt by writing a second row with a higher ts directly through the store path - // would bypass create(); instead assert the comparator orders a higher createdAt first. + const older = ts.create("old", "flow", "{}"); const list = ts.list(); expect(list.every((s, i) => i === 0 || list[i - 1].createdAt >= s.createdAt)).toBe(true); expect(list.map((s) => s.id)).toContain(older.id); @@ -44,29 +49,36 @@ describe("TemplateStore", () => { const store = new Store(); const ts = new TemplateStore(store); expect(ts.get("tpl_nope")).toBeNull(); - // A malformed row in the reserved collection must not crash list()/get(). store.set(TEMPLATES_WSID, { project: "tpl_bad", agent: "_", type: "template", content: "{not json", description: "bad", ts: 1 }); expect(ts.get("tpl_bad")).toBeNull(); expect(ts.list()).toHaveLength(0); }); - it("persists across sessions: a fresh store hydrated from the same rows resolves the template by id", () => { - // Session 1 — create a template; its rows are what a persistence backend would save. + it("reads a LEGACY template (free-text hints, no schema) for back-compat", () => { + const store = new Store(); + const ts = new TemplateStore(store); + // A row written by the old code path: a `hints` string, no `schema`. + const body = JSON.stringify({ name: "Legacy", type: "flow", content: "{}", hints: "WHAT: legacy note", createdAt: 5 }); + store.set(TEMPLATES_WSID, { project: "tpl_legacy", agent: "_", type: "template", content: body, description: "Legacy", ts: 5 }); + const got = ts.get("tpl_legacy"); + expect(got!.hints).toBe("WHAT: legacy note"); + expect(got!.schema).toBeUndefined(); + }); + + it("persists across sessions: a fresh store hydrated from the same rows resolves it + its schema", () => { const store1 = new Store(); const ts1 = new TemplateStore(store1); - const t = ts1.create("Quarterly revenue", "vegalite", '{"mark":"bar"}', "ADAPT: swap the quarter", "rev"); + const t = ts1.create("Quarterly revenue", "vegalite", '{"mark":"bar"}', SCHEMA, "rev"); const persisted = store1.list(TEMPLATES_WSID); // == PersistenceBackend.save payload - // Session 2 — a brand-new store (new container/session) hydrated from those persisted rows. - const store2 = new Store(); + const store2 = new Store(); // a new container/session for (const row of persisted) store2.set(TEMPLATES_WSID, row); const ts2 = new TemplateStore(store2); const got = ts2.get(t.id); expect(got).not.toBeNull(); - expect(got!.name).toBe("Quarterly revenue"); expect(got!.content).toBe('{"mark":"bar"}'); - expect(got!.hints).toBe("ADAPT: swap the quarter"); + expect(got!.schema).toEqual(SCHEMA); expect(ts2.list().map((s) => s.id)).toContain(t.id); }); }); diff --git a/plugin/commands/diagram-remote.md b/plugin/commands/diagram-remote.md index 1ce9759..2f38852 100644 --- a/plugin/commands/diagram-remote.md +++ b/plugin/commands/diagram-remote.md @@ -33,10 +33,11 @@ Do this: 2. **Scope it:** `--project` = this repo, `--agent` = your agent id (so concurrent agents don't clash). **Iterating on an earlier view? Don't rebuild it** — `termchart list`, then `termchart pull --project --agent ` to fetch the spec, edit, and push it back. - **Reusing a SAVED template?** `termchart template list` → `template get ` (prints adaptation - hints + an example), then author fresh data and push it — see the diagram-recipes skill's - "Reusable templates" section. (Templates are the user's saved diagrams, distinct from this skill's - shipped recipes.) + **Reusing a SAVED template?** `termchart template list` → `template get ` (prints the data + **schema** — the fields to supply — then an example), then author a conforming diagram and push it. + **Want a board you push now to be reusable later?** attach `--schema ` (its data fields + + descriptions). See the diagram-recipes skill's "Reusable templates" section. (Templates are the + user's saved diagrams, distinct from this skill's shipped recipes.) **For a live / step-by-step view (a build, a deploy, a run), push it once then `termchart patch` each change** — a small delta, applied in place (no flicker): `flow` `setNodeData`, `component` `setProps`/`setChildren`, or `panes` `patchPane`. Don't re-push the whole spec per step. diff --git a/plugin/skills/diagram-recipes/SKILL.md b/plugin/skills/diagram-recipes/SKILL.md index 08317af..29b22eb 100644 --- a/plugin/skills/diagram-recipes/SKILL.md +++ b/plugin/skills/diagram-recipes/SKILL.md @@ -197,31 +197,33 @@ keep the structure. ## Reusable templates (≠ recipes) -A **template** is a diagram the user already built and **saved** (in the viewer UI or via the CLI), -addressed by a `tpl_…` **id** and reusable from **any future session**. It decouples the *structure* of -a good diagram from its *data* and carries free-text **hints** that tell you how to adapt it. This is +A **template** is a diagram the user saved (the **Templates** button in the viewer, or `termchart +template save`), addressed by a `tpl_…` **id** and reusable from **any future session**. The reuse +guidance is a **data schema the author attached when pushing the board** — not a human note. This is distinct from the **recipes** in this skill: recipes are the *shipped, generic starting patterns* you reach for when authoring a **new** diagram; templates are the *user's own saved diagrams* you reach for when they want to **reuse a specific one**. -**Reusing a template (the future-session workflow):** -1. `termchart template list` — see saved templates (`id · [type] · name`). -2. `termchart template get ` — prints the **HINTS first** (how to adapt), then the **example - content** (a concrete, working diagram of that `type`). -3. Read the hints, **author fresh data** into a copy of the example (keep the structure/styling the - hints say to keep; swap the data slots), then `termchart push --type …` it like any diagram. - -There's no deterministic "apply" — *you* (the agent) do the adaptation, which is why the hints matter. - -**Saving a template** (so a future you can reuse it): `termchart template save --project

--agent - --name "" --hints ""`, or the **Templates** button in the viewer. Write hints with this -scaffold so they're actionable later: -``` -WHAT: -DATA SLOTS: -ADAPT: -KEEP: +**Make a board reusable — attach a `--schema` when you push it.** If a board is worth templating, push +it pre-decomposed: the diagram (a working example) plus a `schema` JSON that names the **data** parts +(vs the fixed structure) so a future agent can supply new data: +```bash +termchart push --project --agent --type datatable --description "…" board.json \ + --schema schema.json +# schema.json: { "summary": "monthly spend by service", +# "fields": [ { "name": "rows", "type": "array", +# "description": "one row per service: [service, region, monthly_usd, delta, owner]" } ] } ``` +`fields[].{name, description}` are required; `type`/`example`/`summary` optional. Saving the board as a +template captures this schema automatically — **nothing for the human to fill in**. + +**Reusing a template (the future-session workflow):** +1. `termchart template list` — saved templates (`id · [type] · name`). +2. `termchart template get ` — prints the **DATA SCHEMA first** (the fields + descriptions to + supply), then the **example content** (a working diagram of that `type`). +3. Read the schema, **author a conforming diagram** (keep the structure; fill the data fields the schema + describes), then `termchart push --type …` it like any diagram. There's no deterministic + "apply" — *you* (the agent) author the diagram, guided by the schema. ## Tips