diff --git a/.changeset/simplify-gather-markdown.md b/.changeset/simplify-gather-markdown.md new file mode 100644 index 00000000..d7ddf21b --- /dev/null +++ b/.changeset/simplify-gather-markdown.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +Present gather Markdown as task guidance while keeping package diagnostics in JSON. diff --git a/README.md b/README.md index 5a1690f0..9f00d0af 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ known. ghost init # scaffold .ghost/ with a robust provisional baseline ghost checks init # opt in to review assertions ghost validate # make sure the package is well-formed -ghost gather [ask] # before building: show the complete guidance menu +ghost gather # before building: show the complete guidance menu ghost pull # read the picked nodes' full bodies ghost review # during review: match a diff to guidance and checks ghost stats # while tuning: see what agents reached for diff --git a/packages/context-control/README.md b/packages/context-control/README.md index d72c5117..bca67e14 100644 --- a/packages/context-control/README.md +++ b/packages/context-control/README.md @@ -29,11 +29,10 @@ else `ghost` on PATH). ## Screens -**package** — the catalog rendered as the selection surface the model -sees: id, kind, `for` payload, material count, coverage line. Click a node to -see its real `ghost pull` output in a drawer. Review `for` payloads as -retrieval payloads, not file contents; a node with no `for` is flagged -as invisible. +**package** — the catalog behind the selection surface: id, kind, `for` +payload, material count, and coverage. Click an item to see its real `ghost +pull` output in a drawer. Review `for` payloads as retrieval payloads, not file +contents; an item with no `for` is flagged as invisible. **bench** — type an ask (or run the whole asks suite), fire N single-shot selection trials, and read the heatmap: nodes × asks, each cell the @@ -43,11 +42,11 @@ the ask's expected set. Scores above the map: consistency (mean pairwise Jaccard), mean per-trial precision and recall, poison-selection rate, unknown ids, and nodes ever selected. -Selection runs as a real agent would: the system prompt includes the cover -already in context and asks for a small pull from the menu. There is no -skill-less mode. One caveat remains: a live agent also -carries task context (open files, prior turns) that single-shot selection -lacks. +Selection runs against the exact agent-facing Markdown from `ghost gather +`. The system prompt only requests applicable IDs; it does not reconstruct +the menu or repeat Ghost's selection mechanics. One caveat remains: a live +agent also carries task context (open files, prior turns) that the single-shot +selector lacks. **replay** — the real `.ghost/.events` tape grouped into sessions: each gather with its ask, the pulls that followed, re-gathers, and pull misses. @@ -96,8 +95,8 @@ Add providers to `MODEL_ADAPTERS` in `lib/model.mjs`. ```text cli.mjs # context-control → serves the UI -lib/ghost.mjs # shells ghost gather/pull --format json (never re-implements semantics) -lib/model.mjs # model adapters (fake-lexical stub) +lib/ghost.mjs # shells exact Markdown for trials; JSON for inspection and pull +lib/model.mjs # model adapters; real models receive literal gather Markdown lib/bench.mjs # trial runner + asks.md parser lib/score.mjs # jaccard, consistency, precision/recall, rates, coverage lib/tape.mjs # .ghost/.events parser + session grouping diff --git a/packages/context-control/lib/bench.mjs b/packages/context-control/lib/bench.mjs index 484c1123..8c46c2b7 100644 --- a/packages/context-control/lib/bench.mjs +++ b/packages/context-control/lib/bench.mjs @@ -8,6 +8,7 @@ export async function runAsk({ ask, menu, cover, + markdown, trials = 5, expected, poison, @@ -15,7 +16,7 @@ export async function runAsk({ const known = new Set(menu.map((entry) => entry.id)); const selections = await Promise.all( Array.from({ length: trials }, async (_, trial) => { - const ids = await model.select({ ask, menu, cover, trial }); + const ids = await model.select({ ask, menu, cover, markdown, trial }); return { ids: ids.filter((id) => known.has(id)), unknownIds: ids.filter((id) => !known.has(id)), diff --git a/packages/context-control/lib/ghost.mjs b/packages/context-control/lib/ghost.mjs index f176413f..16e7b7d8 100644 --- a/packages/context-control/lib/ghost.mjs +++ b/packages/context-control/lib/ghost.mjs @@ -52,6 +52,11 @@ export async function gatherMenu({ ghostBin, packageDir }) { return JSON.parse(stdout); } +export async function gatherMarkdown({ ghostBin, packageDir, ask }) { + if (!ask?.trim()) throw new Error("gather Markdown needs an ask"); + return runGhost(ghostBin, ["gather", ask, "--package", packageDir]); +} + export async function pullNode({ ghostBin, packageDir, id }) { const stdout = await runGhost(ghostBin, [ "pull", diff --git a/packages/context-control/lib/markdown.mjs b/packages/context-control/lib/markdown.mjs new file mode 100644 index 00000000..50f51918 --- /dev/null +++ b/packages/context-control/lib/markdown.mjs @@ -0,0 +1,44 @@ +const SECTION_HEADING = /^##\s+Available guidance\s*$/mu; +const GROUP_HEADING = /^###\s+(.+?)\s*$/u; +const ENTRY = /^-\s+`([^`]+)`\s*$/u; +const APPLIES = /^\s+-\s+Applies when:\s*(.+?)\s*$/u; + +/** Parse the exact agent-facing gather Markdown without reconstructing it. */ +export function parseGatherMarkdown(markdown) { + const match = SECTION_HEADING.exec(markdown); + if (!match || match.index === undefined) { + throw new Error("gather Markdown has no Available guidance section"); + } + + const guidance = markdown.slice(0, match.index).trim(); + const available = markdown.slice(match.index).trim(); + const nodes = []; + let kind; + let current; + + for (const line of available.split(/\r?\n/u).slice(1)) { + const group = GROUP_HEADING.exec(line); + if (group) { + kind = group[1] === "Other guidance" ? undefined : group[1]; + current = undefined; + continue; + } + + const entry = ENTRY.exec(line); + if (entry) { + current = { + id: entry[1], + ...(kind ? { kind } : {}), + }; + nodes.push(current); + continue; + } + + const applies = APPLIES.exec(line); + if (applies && current) { + current.for = applies[1] === "not stated." ? undefined : applies[1]; + } + } + + return { guidance, markdown, nodes }; +} diff --git a/packages/context-control/lib/model.mjs b/packages/context-control/lib/model.mjs index f5336ea9..2973b472 100644 --- a/packages/context-control/lib/model.mjs +++ b/packages/context-control/lib/model.mjs @@ -76,37 +76,19 @@ export function fakeModel() { }; } -// The selection prompt is a replica of what a real skill-equipped agent -// follows: the skill bundle's recall and brief recipes -// (packages/ghost/src/skill-bundle/references/). The bench measures actual -// agent behavior, so the protocol those recipes install is always on — -// there is no skill-less arm. If the recipes change, change this with them. -const SELECT_SYSTEM = `You are an agent selecting brand guidance nodes for a task, -following the ghost skill's recall recipe. +const SELECT_SYSTEM = `Select the guidance IDs that apply to the task. +Follow the instructions in the supplied guidance. Respond with ONLY a JSON +array of ID strings, nothing else.`; -You will get an ask, the cover already in context, and the ghost gather menu. -Select only menu node ids against their contexts. Do not select the cover. +function selectUser(ask, menu, cover, markdown) { + if (markdown) return markdown; -- Pull every node whose context indicates its stated situation applies and - whose guidance, material, structure, or refusal governs the work. -- Skip inapplicable nodes. Topic overlap alone is not applicability. -- Do not add nodes for completeness or omit applicable nodes to meet a count. -- Anti-goal nodes are review-critical negative space; pull each one whose - context names territory the ask enters. - -Respond with ONLY a JSON array of node id strings, nothing else.`; - -function selectUser(ask, menu, cover) { - const lines = menu.map((entry) => { - const flags = [entry.materials ? `${entry.materials} materials` : null] - .filter(Boolean) - .join(", "); - return `- ${entry.id}${entry.kind ? ` [${entry.kind}]` : ""}${flags ? ` (${flags})` : ""}: ${entry.for ?? "(no for payload)"}`; - }); - const coverLine = cover - ? `Cover already in context: ${cover.id}\n\n${cover.body}\n\n` - : ""; - return `${coverLine}Ask: ${ask}\n\nMenu:\n${lines.join("\n")}`; + // Compatibility path for callers that still supply the JSON gather result. + const lines = menu.map( + (entry) => `- ${entry.id}: ${entry.for ?? "(applicability not stated)"}`, + ); + const guidance = cover?.body ? `${cover.body}\n\n` : ""; + return `${guidance}Task: ${ask}\n\nAvailable guidance:\n${lines.join("\n")}`; } /** Parse a JSON id array out of a model reply, tolerating code fences. */ @@ -139,7 +121,7 @@ export function openAICompatibleModel({ } return { name: "openai-compatible", - async select({ ask, menu, cover }) { + async select({ ask, menu, cover, markdown }) { const res = await fetch( `${baseUrl.replace(/\/$/, "")}/chat/completions`, { @@ -152,7 +134,10 @@ export function openAICompatibleModel({ model, messages: [ { role: "system", content: SELECT_SYSTEM }, - { role: "user", content: selectUser(ask, menu, cover) }, + { + role: "user", + content: selectUser(ask, menu, cover, markdown), + }, ], // Trial-to-trial variance is the signal being measured, so sample at // the endpoint's default temperature rather than pinning it to zero. diff --git a/packages/context-control/lib/server.mjs b/packages/context-control/lib/server.mjs index 950061cf..5effa708 100644 --- a/packages/context-control/lib/server.mjs +++ b/packages/context-control/lib/server.mjs @@ -4,7 +4,8 @@ import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; import { fileURLToPath } from "node:url"; import { parseAsks, runAsk } from "./bench.mjs"; -import { gatherMenu, pullNode } from "./ghost.mjs"; +import { gatherMarkdown, gatherMenu, pullNode } from "./ghost.mjs"; +import { parseGatherMarkdown } from "./markdown.mjs"; import { availableModels, resolveModel } from "./model.mjs"; import { suiteCoverage } from "./score.mjs"; import { readTape, toSessions } from "./tape.mjs"; @@ -44,16 +45,28 @@ export function startServer({ ghostBin, packageDir, asksPath, port = 4114 }) { } const gathered = await gatherMenu({ ghostBin, packageDir }); const menu = gathered.nodes; + const knownIds = new Set(menu.map((entry) => entry.id)); const model = resolveModel(body.model); const asks = Array.isArray(body.asks) ? body.asks : [body]; const results = []; for (const item of asks) { + const markdown = await gatherMarkdown({ + ghostBin, + packageDir, + ask: item.ask, + }); + const parsed = parseGatherMarkdown(markdown); + for (const id of [...(item.expected ?? []), ...(item.poison ?? [])]) { + if (!knownIds.has(id)) { + throw new Error(`ask references unknown node id: ${id}`); + } + } results.push( await runAsk({ model, ask: item.ask, - menu, - cover: gathered.cover, + menu: parsed.nodes, + markdown, trials, expected: item.expected ?? null, poison: item.poison ?? [], diff --git a/packages/context-control/test/context-control.test.ts b/packages/context-control/test/context-control.test.ts index cf5ebb8d..dd9b4569 100644 --- a/packages/context-control/test/context-control.test.ts +++ b/packages/context-control/test/context-control.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { initGhostPackage } from "../../ghost/src/package.js"; import { parseAsks } from "../lib/bench.mjs"; +import { parseGatherMarkdown } from "../lib/markdown.mjs"; import { openAICompatibleModel, parseIdReply } from "../lib/model.mjs"; import { consistency, @@ -142,6 +143,44 @@ describe("demo asks", () => { }); }); +describe("parseGatherMarkdown", () => { + it("reads the exact agent-facing guidance and available IDs", () => { + const parsed = parseGatherMarkdown( + [ + "# Guidance for this task", + "", + "Task: Build a page.", + "", + "Brand guidance.", + "", + "## Available guidance", + "", + "Check every item.", + "", + "### foundation", + "", + "- `foundation.color`", + " - Applies when: Choosing color.", + "", + "### Other guidance", + "", + "- `voice`", + " - Applies when: not stated.", + ].join("\n"), + ); + + expect(parsed.guidance).toContain("Brand guidance."); + expect(parsed.nodes).toEqual([ + { + id: "foundation.color", + kind: "foundation", + for: "Choosing color.", + }, + { id: "voice" }, + ]); + }); +}); + describe("openAICompatibleModel", () => { it("requires portable endpoint configuration", () => { expect(() => openAICompatibleModel({})).toThrow("CONTEXT_CONTROL_BASE_URL"); diff --git a/packages/ghost/README.md b/packages/ghost/README.md index 61813495..89f87426 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -43,7 +43,7 @@ Your agent works with the package through a small set of commands: ghost init # scaffold .ghost/ with the starter package ghost checks init # opt in to review assertions ghost validate # make sure the package is well-formed -ghost gather [ask] # before building: show the complete guidance menu +ghost gather # before building: show the complete guidance menu ghost pull # read the picked nodes' full bodies ghost review # during review: match a diff to guidance and checks ghost stats # while tuning: see what agents reached for diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index aba6fa65..bb8e7789 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -124,7 +124,7 @@ const COMMAND_DISCOVERY = [ name: "gather", group: "core", defaultHelp: true, - compactName: "gather [ask]", + compactName: "gather ", summary: "Emit the complete guidance menu so the agent can pull applicable nodes.", }, diff --git a/packages/ghost/src/commands/gather-command.ts b/packages/ghost/src/commands/gather-command.ts index 67448f0c..5815346c 100644 --- a/packages/ghost/src/commands/gather-command.ts +++ b/packages/ghost/src/commands/gather-command.ts @@ -1,5 +1,5 @@ import type { CAC } from "cac"; -import type { CatalogMenuEntry } from "#ghost-core"; +import { type CatalogMenuEntry, UsageError } from "#ghost-core"; import type { GhostGatherResult } from "../embed/index.js"; import { gatherGhostPackage, loadGhostSnapshot } from "../embed/index.js"; import { appendGhostEvent, resolveRunId } from "../observability-events.js"; @@ -34,6 +34,11 @@ export function registerGatherCommand(cli: CAC): void { const ask = normalizeAskParts(askParts); const paths = resolveGhostPackage(opts.package, process.cwd()); const snapshot = await loadGhostSnapshot(paths); + if (format === "markdown" && ask === undefined) { + throw new UsageError( + "gather needs a task. Run `ghost gather `. For package inspection, use `ghost gather --format json`.", + ); + } const menu = gatherGhostPackage(snapshot, { ask }); const runId = resolveRunId(opts.run); await appendGhostEvent(paths.packageDir, { @@ -89,93 +94,57 @@ function formatGatherJson(menu: GhostGatherResult): Record { }; } -function menuCoverageLine(menu: GhostGatherResult): string { - const coverage = menu.coverage; - const parts = [`${coverage.nodes} nodes`]; - if (coverage.concrete > 0) { - parts.push(`${coverage.concrete} with concrete support`); - } else { - parts.push("all prose, no concrete support"); - } - if (coverage.withoutFor > 0) { - parts.push(`${coverage.withoutFor} lack \`for\` payloads`); - } - return parts.join(" · "); -} +const NO_GUIDANCE_HEADING = /^##[ \t]+If no guidance applies[ \t]*$/im; function formatMenuMarkdown(menu: GhostGatherResult): string { - const lines: string[] = ["# ghost package", ""]; - if (menu.ask) lines.push(`Ask: ${menu.ask}`, ""); + if (!menu.ask) { + throw new UsageError("Markdown gather output requires a task."); + } - // Selection contract first: ghost's own instructions occupy the most - // privileged position, ahead of any package-authored prose. - lines.push( - "## Selection contract", + const lines: string[] = [ + "# Guidance for this task", "", - "Complete and unfiltered: every selectable node appears below; nothing was pre-selected.", - menu.contract.selection.instruction, + `Task: ${menu.ask}`, "", - ); - if (!menu.ask) { - lines.push(menu.contract.noAsk, ""); - } - lines.push(menu.silence.ifNoneApply, "", "---", ""); + ]; if (menu.cover.state === "resolved") { + lines.push(menu.cover.node.body, ""); + } + if ( + menu.cover.state !== "resolved" || + !NO_GUIDANCE_HEADING.test(menu.cover.node.body) + ) { lines.push( - `## Cover: \`${menu.cover.id}\``, + "## If no guidance applies", "", - menu.cover.node.body, - "", - "This cover is already in context and is not selectable.", - "", - "---", + menu.cover.state === "resolved" + ? "Continue with ordinary reasoning for reversible choices unless the guidance above requires input. Ask before consequential, irreversible, or brand-defining choices." + : "Continue with ordinary reasoning for reversible choices. Ask before consequential, irreversible, or brand-defining choices.", "", ); } - lines.push("## Available guidance", "", menuCoverageLine(menu), ""); lines.push( - `Evaluate all ${menu.nodes.length} selectable nodes. Numbering is for counting only. Pull by id.`, + "## Available guidance", + "", + "Check every item below. Pull all applicable IDs together with `ghost pull […]`. Skip clear non-matches; topic overlap alone is not enough. Do not limit the number.", "", ); const groups = groupMenuByKind(menu.nodes, menu.kinds ?? []); - const kindPurpose = new Map( - (menu.kinds ?? []).map((kind) => [kind.name, kind.purpose]), - ); - let index = 0; for (const group of groups) { - if (group.kind) { - lines.push(`### ${group.kind}`, ""); - const purpose = kindPurpose.get(group.kind); - if (purpose) lines.push(purpose, ""); - } else { - lines.push( - "### Uncategorized", - "", - "These nodes have no kind. Use the selection contract as written.", - "", - ); - } + lines.push(group.kind ? `### ${group.kind}` : "### Other guidance", ""); for (const entry of group.entries) { - index += 1; - lines.push(`${index}. \`${entry.id}\``); - lines.push( - entry.for?.trim() - ? ` - Applies when: ${entry.for.trim()}` - : " - Applicability unstated: no `for` payload.", - ); - const metadata = formatMetadata(entry); - if (metadata.length > 0) { - lines.push(` - ${metadata.join("; ")}`); + lines.push(`- \`${entry.id}\``); + if (entry.for?.trim()) { + lines.push(` - Applies when: ${entry.for.trim()}`); } } lines.push(""); } - lines.push("Next: `ghost pull […]`."); - return `${lines.join("\n")}\n`; + return `${lines.join("\n").trimEnd()}\n`; } interface MenuGroup { @@ -217,22 +186,3 @@ function groupMenuByKind( return orderedKeys.map((kind) => ({ kind, entries: groups.get(kind) ?? [] })); } - -function formatMetadata(entry: CatalogMenuEntry): string[] { - const metadata: string[] = []; - if (entry.materials !== undefined) { - metadata.push(`materials: ${entry.materials}`); - } - const payloadTypes = formatPayloadTypes(entry); - if (payloadTypes.length > 0) { - metadata.push(`payloads: ${payloadTypes.join(", ")}`); - } - return metadata; -} - -function formatPayloadTypes(entry: CatalogMenuEntry): string[] { - const types: string[] = []; - if (entry.hasFencedExample) types.push("substantial fenced example"); - if (entry.hasSkeleton) types.push("Skeleton"); - return types; -} diff --git a/packages/ghost/src/init-payloads/skeleton/brand.md b/packages/ghost/src/init-payloads/skeleton/brand.md index 74d5da9d..bfe3f373 100644 --- a/packages/ghost/src/init-payloads/skeleton/brand.md +++ b/packages/ghost/src/init-payloads/skeleton/brand.md @@ -2,26 +2,12 @@ for: Any task that should express this brand. --- -This cover is unwritten. ghost gather always places this page in an agent's -context; that is delivery status, not a claim that every sentence applies to -every task. Use it for what cannot be retrieved by task: what this brand is about, -in one paragraph, in the brand's own voice. The temperature its words and -motion share. And the refusals only this brand makes — not generic don'ts, -but the lines this brand alone draws. +Use a quiet, precise, content-first stance until the brand owner replaces or +accepts it. Remove decoration that does not help the reader understand or act. +Do not invent brand principles, palettes, type choices, voice rules, or other +identity decisions. -Until a human writes it, the working stance is: quiet, precise, content -first, decoration never. Treat that as provisional and say so in your -report. +## If no guidance applies -One test admits a sentence to this page: where would a violation show? In a -single element, it belongs in that element's chapter. In a single view, it -belongs in the composition foundation. Only across the whole body of work — -temperature, density, restraint — it belongs here. - -Generic don'ts do not live here either: element-scoped rejections live in -each foundation's Never section, and the model's measured defaults live in -the shared `standard.model-defaults` node. This page holds only the refusals -this brand alone makes. - -The budget is one screen. When this page is real, delete every sentence of -scaffolding above — including this one. +Continue with ordinary reasoning for reversible choices. Ask before +consequential, irreversible, or brand-defining choices. diff --git a/packages/ghost/src/init-payloads/skeleton/glossary.md b/packages/ghost/src/init-payloads/skeleton/glossary.md index 8d01fcaa..fa1ac150 100644 --- a/packages/ghost/src/init-payloads/skeleton/glossary.md +++ b/packages/ghost/src/init-payloads/skeleton/glossary.md @@ -7,9 +7,9 @@ kinds: # standard -Shared guidance that is not specific to this brand. Pull every standard node -whose `for` payload matches the task. Within matching nodes, Obligations cannot -be waived; Defaults yield to explicit brand guidance. +Shared guidance that is not specific to this brand. Use each item when its +`Applies when` condition fits the task. Obligations cannot be waived; Defaults +yield to explicit brand guidance. Every rule carries one of two authority labels. An **Obligation** is a requirement brand preference cannot waive: accessibility, safety, honesty, diff --git a/packages/ghost/src/scan/package-lint.ts b/packages/ghost/src/scan/package-lint.ts index dff6d6a4..a778ae5b 100644 --- a/packages/ghost/src/scan/package-lint.ts +++ b/packages/ghost/src/scan/package-lint.ts @@ -148,6 +148,16 @@ function lintCover( return; } + if (!/^##[ \t]+If no guidance applies[ \t]*$/im.test(cover.body)) { + issues.push({ + severity: "warning", + rule: "cover-no-guidance-policy-missing", + message: + 'cover has no "## If no guidance applies" section; add the continuation or escalation rule for uncovered decisions', + path: `${coverId}.md`, + }); + } + const bytes = Buffer.byteLength(cover.body, "utf-8"); if (bytes > 1500) { issues.push({ diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index eb9290c4..c0985d9e 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -71,14 +71,15 @@ ghost review # assemble diff + matched material-backed nodes + checks ghost stats # summarize local gather/pull events while tuning ``` -`gather` does no selection. It emits the selection contract, the cover when -resolved, then the complete, unfiltered, unranked menu of every selectable node. -The emitted contract owns the pull rule; kind legends can narrow it. Declared -kinds render in glossary order, undeclared kinds alphabetically, and -uncategorized nodes last. Its coverage line reports total selectable nodes, -concrete support, and missing `for` payloads. Each node labels applicability, -then any material count, substantial fenced example, or Skeleton metadata, so -an all-prose package is visible before generation. +`gather` does no selection. Its Markdown is an agent-facing instruction +surface: the task, the cover guidance without a machinery label, then every +available id and its applicability. Check the full list and pull every id that +applies. Declared kind headings render in glossary order, undeclared kinds +alphabetically, and uncategorized guidance last. +Markdown omits package diagnostics that do not change the next action. JSON +retains the cover state, selection contract, coverage, materials, substantial +fenced examples, Skeletons, and missing `for` payloads for integrations and +audits. Prefer `ghost pull` over reading files directly: it emits the same prose, inlines small local materials by default, turns binary materials into diff --git a/packages/ghost/src/skill-bundle/references/ground.md b/packages/ghost/src/skill-bundle/references/ground.md index 4584ba80..64e53698 100644 --- a/packages/ghost/src/skill-bundle/references/ground.md +++ b/packages/ghost/src/skill-bundle/references/ground.md @@ -10,15 +10,14 @@ should be shaped by a ghost package. ## Gather and select -Run `ghost gather ` with the real task, not a generic label. The cover is -inlined by gather, so do not pull it separately. - -`gather` presents every selectable node; it does not filter or rank, and its -selection contract states the pull rule. Kind legends may narrow that global -rule, including stricter handling when uncertain. - -Read the coverage line before you choose. It tells you whether the package has -concrete material and whether any node lacks a `for` payload. +Run `ghost gather ` with the real task, not a generic label. Read the +supplied guidance, then check every item under `Available guidance`. Pull every +id whose `Applies when` condition fits the task. Skip clear non-matches; topic +overlap alone is not enough. + +The guidance before `Available guidance` is already supplied. Do not pull it +again. If nothing in the list applies, follow its `If no guidance applies` +section. ## Pull and inspect diff --git a/packages/ghost/src/skill-bundle/references/making.md b/packages/ghost/src/skill-bundle/references/making.md index 9a87ffdb..169062ff 100644 --- a/packages/ghost/src/skill-bundle/references/making.md +++ b/packages/ghost/src/skill-bundle/references/making.md @@ -17,7 +17,7 @@ judges, repairs, and reviews in the same session. ## Ground Follow [ground.md](ground.md), which ends with the anchor: gather with the real -ask, select and pull by the menu's selection contract, and inspect decisive +ask, pull every applicable id from the available guidance, and inspect decisive materials before generating. Use this triage for material inspection: diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 38c9c4c1..8dbf4314 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -26,7 +26,9 @@ node id. When present and resolved, `ghost gather` inlines that node above the menu on every invocation. `ghost validate` reports a missing referenced cover as an error, an undeclared -cover as a warning, and a cover body over 1500 bytes as a warning. +cover as a warning, a cover body over 1500 bytes as a warning, and a cover +without an exact `## If no guidance applies` section as a warning. That section +tells the agent when to continue and when to ask about uncovered decisions. ## Glossary and identity @@ -39,13 +41,11 @@ Obligation or a replaceable Default), `foundation` (the brand's load-bearing decisions), and `context` (what bends in a named situation). A package may declare any vocabulary; the glossary is the only kind authority. -`ghost gather` renders each kind's **first paragraph only** as its menu -legend; later paragraphs are dropped. Write that first paragraph as -selection semantics: when to pull this kind, and any routing rule. Do not make -it anatomy, history, or rationale for what the kind does not yet cover. Put -anatomy and history in the paragraphs after it. Declared kinds render in -frontmatter order even when their purpose paragraph is empty; undeclared kinds -render alphabetically after declared kinds, and uncategorized nodes render last. +`ghost gather --format json` includes each kind's first paragraph as its +purpose for operator tooling. Agent-facing Markdown uses kind headings only; +selection comes from each item's `Applies when` condition. Declared kinds render +in frontmatter order even when their purpose is empty; undeclared kinds render +alphabetically after declared kinds, and uncategorized guidance renders last. ## Nodes @@ -116,10 +116,12 @@ it does not grade them. ## Command behavior -- `ghost gather` emits the selection contract, the resolved cover when present, - coverage counts, then a complete, unfiltered, unranked menu of every - selectable node. It groups declared kinds in glossary order, undeclared kinds - alphabetically, and uncategorized nodes last. Checks are absent. +- `ghost gather ` emits agent-facing Markdown: the task, the resolved + cover body without a machinery label, then every available id and its + applicability. It groups declared kinds in glossary order, undeclared kinds + alphabetically, and uncategorized guidance last. Checks and diagnostic + metadata are absent. `--format json` retains the cover state, selection + contract, coverage, kind metadata, and concrete payload metadata for tooling. - `ghost pull` emits selected nodes in steering order, inlines eligible local text materials once, leaves later duplicate pointers, turns binary materials into inspect-pointers, and leaves external materials as locators. diff --git a/packages/ghost/src/skill-bundle/references/steering-audit.md b/packages/ghost/src/skill-bundle/references/steering-audit.md index ddcf5454..f3994d59 100644 --- a/packages/ghost/src/skill-bundle/references/steering-audit.md +++ b/packages/ghost/src/skill-bundle/references/steering-audit.md @@ -25,8 +25,8 @@ Report first: - **Concreteness coverage:** total nodes, concrete-material nodes, prose-only nodes. Concrete means non-empty `materials`, a fenced code block of at least 3 - lines, or a `## Skeleton` section. `ghost gather` reports material counts and - labels substantial fenced examples and Skeletons as payload metadata. + lines, or a `## Skeleton` section. Read these counts and payload labels from + `ghost gather --format json`; agent-facing Markdown omits them. - **Pull rate by concreteness:** concrete-material exposure/pull rate vs prose-only exposure/pull rate. In markdown this is the `Concrete material` row. This is the tuning instrument: if concrete nodes are not pulled when applicable, diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index 45ddb3e8..1c153b8a 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -38,7 +38,7 @@ async function writeBareTestPackage(dir: string): Promise { ), writeFile( join(packageDir, "index.md"), - "---\nfor: Test package cover.\n---\n\nTest package.\n", + "---\nfor: Test package cover.\n---\n\nTest package.\n\n## If no guidance applies\n\nContinue with ordinary reasoning.\n", ), writeFile( join(packageDir, "standard.model-defaults.md"), @@ -438,7 +438,7 @@ describe("ghost CLI", () => { it("exits 2 with guidance when no ghost package is present", async () => { // A missing package is a usage error (run `ghost init`), not a raw crash. - const result = await runCli(["gather"], dir, { + const result = await runCli(["gather", "test"], dir, { allowNoExit: true, }); expect(result.code).toBe(2); @@ -478,7 +478,7 @@ describe("ghost CLI", () => { expect(forced.code).toBe(0); await expect( readFile(join(dir, ".ghost", "brand.md"), "utf-8"), - ).resolves.toContain("This cover is unwritten"); + ).resolves.toContain("Use a quiet, precise, content-first stance"); }); it("does not guess arbitrary YAML files are validate.yml", async () => { @@ -584,27 +584,52 @@ describe("ghost CLI", () => { ); }); - it("gather inlines the declared cover and excludes it from the menu", async () => { + it("requires a task for Markdown but keeps bare JSON for inspection", async () => { await runCli(["init"], dir); const markdown = await runCli(["gather"], dir); + expect(markdown.code).toBe(2); + expect(markdown.stderr).toContain("gather needs a task"); + expect(markdown.stderr).toContain("ghost gather --format json"); + + const json = await runCli(["gather", "--format", "json"], dir); + expect(json.code).toBe(0); + expect(JSON.parse(json.stdout).ask).toBeUndefined(); + }); + + it("gather presents the declared cover as guidance and excludes it from the menu", async () => { + await runCli(["init"], dir); + + const markdown = await runCli(["gather", "build", "a", "page"], dir); expect(markdown.code).toBe(0); - expect(markdown.stdout).toContain("## Cover: `brand`"); + expect(markdown.stdout).toContain("# Guidance for this task"); + expect(markdown.stdout).toContain("Task: build a page"); expect(markdown.stdout).toContain( - "This cover is already in context and is not selectable.", + "Use a quiet, precise, content-first stance", ); - expect(markdown.stdout).toContain("This cover is unwritten."); - expect(markdown.stdout).toContain( - "9 nodes · all prose, no concrete support", - ); - expect(markdown.stdout).not.toMatch(/\d+\.\s+`brand`/); + expect(markdown.stdout).toContain("## If no guidance applies"); + expect(markdown.stdout).not.toContain("## Cover:"); + expect(markdown.stdout).not.toContain("already in context"); + expect(markdown.stdout).not.toContain("concrete support"); + expect(markdown.stdout).not.toContain("Selection contract"); + expect(markdown.stdout).not.toContain("selectable node"); + expect(markdown.stdout).not.toContain("`for` payload"); + expect(markdown.stdout).not.toContain("Numbering"); + expect(markdown.stdout).not.toContain("materials:"); + expect(markdown.stdout).not.toContain("payloads:"); + expect(markdown.stdout).not.toContain("package is silent"); + expect(markdown.stdout).not.toContain("ghost-backed"); + expect(markdown.stdout).not.toMatch(/^\d+\.\s+`/m); + expect(markdown.stdout).not.toMatch(/^- `brand`$/m); const json = await runCli(["gather", "--format", "json"], dir); expect(json.code).toBe(0); const payload = JSON.parse(json.stdout); expect(payload.cover).toMatchObject({ id: "brand", - body: expect.stringContaining("This cover is unwritten."), + body: expect.stringContaining( + "Use a quiet, precise, content-first stance", + ), inContext: true, selectable: false, }); @@ -626,15 +651,12 @@ describe("ghost CLI", () => { "schema: ghost.package/v1\nid: local\ncover: missing\n", ); - const markdown = await runCli(["gather"], dir); + const markdown = await runCli(["gather", "build", "a", "page"], dir); expect(markdown.code).toBe(0); expect(markdown.stdout).not.toContain("## Cover:"); - expect(markdown.stdout).not.toContain("Check the resolved cover"); - // With no resolvable cover, brand stays a selectable menu node. - expect(markdown.stdout).toContain( - "10 nodes · all prose, no concrete support", - ); - expect(markdown.stdout).toMatch(/\d+\.\s+`brand`/); + expect(markdown.stdout).toContain("## If no guidance applies"); + // With no resolvable cover, brand stays an available guidance item. + expect(markdown.stdout).toMatch(/^- `brand`$/m); const json = await runCli(["gather", "--format", "json"], dir); expect(json.code).toBe(0); @@ -680,7 +702,7 @@ describe("ghost CLI", () => { const brand = await readFile(join(dir, ".ghost", "brand.md"), "utf-8"); await writeFile( join(dir, ".ghost", "brand.md"), - brand.replace("This cover is unwritten.", "x".repeat(1501)), + `${brand}\n${"x".repeat(1501)}`, ); const oversized = await runCli(["validate", "--format", "json"], dir); expect(oversized.code).toBe(0); @@ -693,6 +715,24 @@ describe("ghost CLI", () => { ); }); + it("validate warns when the cover lacks uncovered-decision guidance", async () => { + await writeBareTestPackage(dir); + await writeFile( + join(dir, ".ghost", "index.md"), + "---\nfor: Test package cover.\n---\n\nTest package.\n", + ); + + const validate = await runCli(["validate", "--format", "json"], dir); + expect(validate.code).toBe(0); + expect(JSON.parse(validate.stdout).issues).toContainEqual( + expect.objectContaining({ + severity: "warning", + rule: "cover-no-guidance-policy-missing", + path: "index.md", + }), + ); + }); + it("pull sorts the cover before other requested nodes", async () => { await runCli(["init"], dir); @@ -704,7 +744,7 @@ describe("ghost CLI", () => { ); }); - it("gather surfaces glossary kind purposes as a menu legend", async () => { + it("keeps glossary kind purposes in JSON and only headings in Markdown", async () => { await runCli(["init"], dir); // JSON carries the glossary's declared kinds with their prose purposes. @@ -717,14 +757,14 @@ describe("ghost CLI", () => { expect(foundation.purpose).toContain("load-bearing decisions"); expect(foundation.purpose).toContain("Pull every foundation chapter"); - // Markdown renders the same legend beside that kind's group. - const markdown = await runCli(["gather"], dir); + // Markdown uses the kind only as navigation; purpose prose stays in JSON. + const markdown = await runCli(["gather", "build", "a", "page"], dir); expect(markdown.stdout).not.toContain("Kinds:"); expect(markdown.stdout).toContain("### foundation"); - expect(markdown.stdout).toContain( + expect(markdown.stdout).not.toContain( "The brand's load-bearing decisions for color", ); - expect(markdown.stdout).toContain(menu.contract.noAsk); + expect(markdown.stdout).not.toContain(menu.contract.noAsk); // A missing glossary degrades to no legend, not an error. await rm(join(dir, ".ghost", "glossary.md")); @@ -770,14 +810,14 @@ describe("ghost CLI", () => { "### alpha", "### beta", "### standard", - "### Uncategorized", + "### Other guidance", ]; for (let index = 1; index < headings.length; index += 1) { expect(markdown.stdout.indexOf(headings[index - 1] ?? "")).toBeLessThan( markdown.stdout.indexOf(headings[index] ?? ""), ); } - expect(markdown.stdout.indexOf("### Uncategorized")).toBeLessThan( + expect(markdown.stdout.indexOf("### Other guidance")).toBeLessThan( markdown.stdout.indexOf("`voice`"), ); }); @@ -840,9 +880,10 @@ describe("ghost CLI", () => { payloads: { materials: 1, fencedExamples: 0, skeletons: 0 }, withoutFor: 0, }); - const markdown = await runCli(["gather"], dir); - expect(markdown.stdout).toContain("4 nodes · 1 with concrete support"); - // No nodes lacking `for`: the coverage line stays quiet about them. + const markdown = await runCli(["gather", "test"], dir); + expect(markdown.stdout).toContain("`asset.tokens`"); + expect(markdown.stdout).not.toContain("concrete support"); + expect(markdown.stdout).not.toContain("materials:"); expect(markdown.stdout).not.toContain("lack `for` payloads"); // A node without a `for` payload is invisible to selection — the coverage @@ -851,13 +892,10 @@ describe("ghost CLI", () => { join(dir, ".ghost", "principle.mute.md"), "---\n{}\n---\n\nContext-free guidance.\n", ); - const gatherMute = await runCli(["gather"], dir); - expect(gatherMute.stdout).toContain( - "5 nodes · 1 with concrete support · 1 lack `for` payloads", - ); - expect(gatherMute.stdout).toContain( - "Applicability unstated: no `for` payload.", - ); + const gatherMute = await runCli(["gather", "test"], dir); + expect(gatherMute.stdout).not.toContain("concrete support"); + expect(gatherMute.stdout).toContain("`principle.mute`"); + expect(gatherMute.stdout).not.toContain("Applies when: not stated."); expect(gatherMute.stdout).toContain("Applies when: Tokens."); const gatherMuteJson = await runCli(["gather", "--format", "json"], dir); expect(JSON.parse(gatherMuteJson.stdout).coverage.withoutFor).toBe(1); @@ -954,11 +992,10 @@ describe("ghost CLI", () => { expect(pattern.hasFencedExample).toBeUndefined(); const markdown = await runCli(["gather", "card"], dir); - expect(markdown.stdout).toContain("payloads: substantial fenced example"); - expect(markdown.stdout).toContain("payloads: Skeleton"); - expect(markdown.stdout).not.toContain( - "payloads: substantial fenced example, Skeleton", - ); + expect(markdown.stdout).toContain("`copy`"); + expect(markdown.stdout).toContain("`pattern.card`"); + expect(markdown.stdout).not.toContain("payloads:"); + expect(markdown.stdout).not.toContain("materials:"); }); it("pull extracts Skeletons last and validate warns on malformed Skeleton sections", async () => { @@ -1150,12 +1187,12 @@ describe("ghost CLI", () => { ).toBe(true); const gatherMarkdown = await runCli(["gather", "checkout", "hero"], dir); - expect(gatherMarkdown.stdout).toContain("# ghost package"); - expect(gatherMarkdown.stdout).toContain("Ask: checkout hero"); + expect(gatherMarkdown.stdout).toContain("# Guidance for this task"); + expect(gatherMarkdown.stdout).toContain("Task: checkout hero"); expect(gatherMarkdown.stdout).toContain("## Available guidance"); expect(gatherMarkdown.stdout).not.toContain(menuPayload.contract.noAsk); - expect(gatherMarkdown.stdout).toContain("### Uncategorized"); - expect(gatherMarkdown.stdout.indexOf("### Uncategorized")).toBeLessThan( + expect(gatherMarkdown.stdout).toContain("### Other guidance"); + expect(gatherMarkdown.stdout.indexOf("### Other guidance")).toBeLessThan( gatherMarkdown.stdout.indexOf("`voice`"), ); @@ -1219,15 +1256,19 @@ describe("ghost CLI", () => { await runCli(["init"], dir); // Explicit flag wins over the environment. - await runCli(["gather", "--run", "settings/2026-07-13T20-00-00Z"], dir, { - env: { GHOST_RUN_ID: "env-run" }, - }); + await runCli( + ["gather", "settings", "--run", "settings/2026-07-13T20-00-00Z"], + dir, + { env: { GHOST_RUN_ID: "env-run" } }, + ); // Environment alone. await runCli(["pull", "foundation.voice"], dir, { env: { GHOST_RUN_ID: "settings/2026-07-13T20-00-00Z" }, }); // Neither: the line looks exactly as it does today. - await runCli(["gather"], dir, { env: { GHOST_RUN_ID: undefined } }); + await runCli(["gather", "settings"], dir, { + env: { GHOST_RUN_ID: undefined }, + }); const events = (await readFile(join(dir, ".ghost", ".events"), "utf-8")) .trim() @@ -1781,10 +1822,10 @@ describe("ghost CLI", () => { "---\nname: secret-check\ndescription: Never served.\nseverity: high\nreferences:\n - asset.logo\n---\n\nGrade it.\n", ); - const md = await runCli(["gather", "--package", ".ghost"], dir); + const md = await runCli(["gather", "logo", "--package", ".ghost"], dir); expect(md.code).toBe(0); expect(md.stdout).toContain("`asset.logo`"); - expect(md.stdout).toContain("materials: 2"); + expect(md.stdout).not.toContain("materials: 2"); expect(md.stdout).not.toContain("secret-check"); const json = await runCli( diff --git a/packages/steering-control/lib/arms.mjs b/packages/steering-control/lib/arms.mjs index c3471c9c..dcfee5fa 100644 --- a/packages/steering-control/lib/arms.mjs +++ b/packages/steering-control/lib/arms.mjs @@ -38,7 +38,7 @@ export function assemblePrompt(config, arm, askN, runK) { { encoding: "utf8" }, ); instructions = gatherInstructions(config, askN, runK); - segments.push(section("ghost gather menu", menu.trim())); + segments.push(section("Guidance for this task", menu.trim())); segments.push(section("Making-loop instructions", instructions)); } diff --git a/packages/vessel-light/.ghost/index.md b/packages/vessel-light/.ghost/index.md index 512ce7ea..2215dd16 100644 --- a/packages/vessel-light/.ghost/index.md +++ b/packages/vessel-light/.ghost/index.md @@ -4,11 +4,10 @@ materials: - materials/tokens.css --- -vessel-light is Vessel without the React package: the design language as a -steering packet for agents writing raw HTML and CSS. +vessel-light is Vessel's design language for agents writing raw HTML and CSS. -Style only with the tokens. Compose only with the closed sets the foundations -enumerate. Use the examples when the task matches; they are not a framework. +Use only the tokens and the foundations' closed sets. Use examples only when +the task matches. Foundations carry Vessel's load-bearing decisions. Their role logic survives adaptation; the answered shape, palette, type, and temperature values stand @@ -29,3 +28,8 @@ the context, never the topic. When no example fits, compose from the foundations; do not invent a new styling system. Before anything ships, run the deletion pass: every element names what breaks if it goes, and the view arrives settled. + +## If no guidance applies + +Compose from the foundations. Ask before adding a styling system, token role, +type family, or interaction pattern.