Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/simplify-gather-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@design-intelligence/ghost": minor
---

Present gather Markdown as task guidance while keeping package diagnostics in JSON.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ask> # before building: show the complete guidance menu
ghost pull <ids> # 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
Expand Down
23 changes: 11 additions & 12 deletions packages/context-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
<ask>`. 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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/context-control/lib/bench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ export async function runAsk({
ask,
menu,
cover,
markdown,
trials = 5,
expected,
poison,
}) {
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)),
Expand Down
5 changes: 5 additions & 0 deletions packages/context-control/lib/ghost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/context-control/lib/markdown.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
47 changes: 16 additions & 31 deletions packages/context-control/lib/model.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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`,
{
Expand All @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions packages/context-control/lib/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 ?? [],
Expand Down
39 changes: 39 additions & 0 deletions packages/context-control/test/context-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ask> # before building: show the complete guidance menu
ghost pull <ids> # 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
Expand Down
2 changes: 1 addition & 1 deletion packages/ghost/src/commands/command-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const COMMAND_DISCOVERY = [
name: "gather",
group: "core",
defaultHelp: true,
compactName: "gather [ask]",
compactName: "gather <ask>",
summary:
"Emit the complete guidance menu so the agent can pull applicable nodes.",
},
Expand Down
Loading
Loading