From 18c59f18fd8dd0715db8d1ae2ac4991d19464413 Mon Sep 17 00:00:00 2001 From: Danny Gillespie Date: Thu, 30 Jul 2026 14:36:46 +0100 Subject: [PATCH] feat(mcp): the canonical prompts become a surface that cannot drift from their source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Torque MCP step 3a. reference/PROMPTS.md is the load-bearing intent every skill implements — "if a skill and its source prompt disagree, the prompt wins". Until now nothing outside this repo could read it. It is now an MCP prompt surface, and the point of the design is that it is DERIVED: scripts/prompts-gen.js emits the registry from PROMPTS.md and plugin-shape byte-matches the committed artifact against a fresh generation, so a hand-edited catalogue fails CI instead of teaching a client a prompt the canonical source never said. That is the README's own thesis applied to itself — a prompt catalogue you trust without re-checking is an unverified guardrail. ADD scripts/prompts-gen.js, mirroring scripts/graph-gen.js. It THROWS on a section carrying a prompt body with no `→ /ratchet:` arrow rather than skipping it: silent omission is how a prompt disappears from the catalogue unnoticed, which is the drift this step exists to prevent. Arguments are derived from the placeholders present in each prompt's own body, so editing a prompt updates its arguments automatically. ADD src/mcp/prompts.js — list() strips the body and internal placeholder spellings; get() substitutes via split/join rather than replace, so client text containing $& stays data instead of becoming replacement syntax. structuredClone before crossing the boundary, so a caller cannot mutate the cached registry. ADD prompts/list + prompts/get on both eras, and `prompts` in the advertised capabilities. ADD test/mcp-prompts.test.js (18 cases) + a plugin-shape drift guard. CHANGE one line of reference/PROMPTS.md: the Master Ignition heading now declares `→ /ratchet:ignite`, which its own command↔prompt map already asserted. That removes the generator's only special case. Built by Codex, verified independently by me — the builder is not the verifier (the seam rule this repo applies to /ratchet:evolve). Codex's own hostile pass found and fixed the one critical: a `## ` line inside a prompt body silently deleted the rest of that prompt, caught by mutation rather than by live data, since no current body contains one. TEST UPDATED, NOT WEAKENED (preflight check 3): mcp-server.test.js S1 asserted the advertised capabilities were exactly {tools, resources}. The server now genuinely advertises prompts, so the old assertion asserted a falsehood. It remains a whole -object deepStrictEqual and now pins three members, so a fourth unannounced capability still fails it. VERIFY, by a different method than the build: driven over the REAL stdio wire rather than in-process — prompts/list returns 16 in deterministic order with body and placeholder stripped; prompts/get substitutes a literal `$&` payload intact; unknown name and missing argument both refuse -32602. The drift guard was proven to discriminate by poisoning the committed JSON and watching plugin-shape fail with "is stale", then restoring. Regenerated twice byte-identically; 0 CR bytes on disk confirmed via Node, not shell quoting. npm test 433 assertions, 12 suites, 0 failed; doctor healthy; preflight clear on all mechanical checks. Traced by: openai-codex-gpt-5 (build) · claude-fable-5 (verification) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YVuGhbyo2rYCyH3XV9TSRw --- CHANGELOG.md | 13 ++ package.json | 3 +- reference/PROMPTS.md | 2 +- scripts/prompts-gen.js | 161 +++++++++++++ src/mcp/prompts.generated.json | 204 +++++++++++++++++ src/mcp/prompts.js | 52 +++++ src/mcp/server.js | 18 +- test/mcp-prompts.test.js | 404 +++++++++++++++++++++++++++++++++ test/mcp-server.test.js | 6 +- test/plugin-shape.test.js | 24 ++ 10 files changed, 881 insertions(+), 6 deletions(-) create mode 100644 scripts/prompts-gen.js create mode 100644 src/mcp/prompts.generated.json create mode 100644 src/mcp/prompts.js create mode 100644 test/mcp-prompts.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 08148b0..3ede54c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The 16 canonical ratchet prompts are now an MCP prompt surface.** `prompts/list` + advertises each prompt and its body-derived required arguments; `prompts/get` substitutes + those arguments into a fresh copy of the canonical body for both supported protocol eras. + The registry is generated from `reference/PROMPTS.md` at build time and byte-matched in + `plugin-shape`, so a stale committed artifact fails CI instead of drifting at runtime. + Unknown prompts and missing or non-string required arguments are **MCP-boundary enforced**; + the instructions inside each returned prompt remain **prompt-level guidance**, not CLI + enforcement. + + + ## [1.0.0] - 2026-07-30 — Boundary Gate 0.2 gated proof; 0.3 the seam; 0.6 the fog; 0.7 the probe; 0.8 closure; 0.9 the write. diff --git a/package.json b/package.json index 7703475..0b9b5cb 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "node": ">=18" }, "scripts": { - "test": "node test/cli.test.js && node test/evolve.test.js && node test/plugin-shape.test.js && node test/concurrency.test.js && node test/mcp-rpc.test.js && node test/mcp-workspace.test.js && node test/mcp-handles.test.js && node test/mcp-repository.test.js && node test/mcp-server.test.js && node test/mcp-toctou.test.js && node test/mcp-entry.test.js", + "test": "node test/cli.test.js && node test/evolve.test.js && node test/plugin-shape.test.js && node test/concurrency.test.js && node test/mcp-rpc.test.js && node test/mcp-workspace.test.js && node test/mcp-handles.test.js && node test/mcp-repository.test.js && node test/mcp-server.test.js && node test/mcp-prompts.test.js && node test/mcp-toctou.test.js && node test/mcp-entry.test.js", "test:concurrency": "node test/concurrency.test.js", + "prompts-gen": "node scripts/prompts-gen.js", "preflight": "node scripts/preflight.js", "ratchet": "node bin/ratchet", "ratchet-evolve": "node bin/ratchet-evolve", diff --git a/reference/PROMPTS.md b/reference/PROMPTS.md index a886578..610801a 100644 --- a/reference/PROMPTS.md +++ b/reference/PROMPTS.md @@ -37,7 +37,7 @@ bad option, or pushes to a higher-yield adjacent move.** Everything else is smok --- -## Master Ignition Prompt +## Master Ignition Prompt → `/ratchet:ignite` ```text Operate as a consequence engine, not an advice engine. diff --git a/scripts/prompts-gen.js b/scripts/prompts-gen.js new file mode 100644 index 0000000..9599423 --- /dev/null +++ b/scripts/prompts-gen.js @@ -0,0 +1,161 @@ +'use strict'; + +// MCP prompt generator — DERIVES the registry, so it can never silently lie. +// +// reference/PROMPTS.md is the canonical prompt source, while the installed MCP runtime +// cannot depend on that document being present. This generator turns each declared prompt +// section into deterministic JSON at build time. plugin-shape byte-matches the committed +// artifact against a fresh build, so source edits either regenerate the wire surface or fail. +// +// Scope of truth: section order, command identity, prompt bodies, and arguments all come +// from reference/PROMPTS.md. A bodiless section is navigation; a body without a command is +// malformed source and throws instead of disappearing from the catalogue. +// +// Traced by: openai-codex-gpt-5 + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const OUT_REL = path.join('src', 'mcp', 'prompts.generated.json'); + +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); + +// --- reader -------------------------------------------------------------------------- + +function promptArguments(body) { + const found = new Map(); + for (const match of body.matchAll(/\[([A-Z][A-Z ]*)\]/g)) { + const placeholder = match[0]; + if (found.has(placeholder)) continue; + const name = match[1] + .replace(/^PASTE /, '') + .toLowerCase() + .replace(/ +/g, '_'); + found.set(placeholder, { name, placeholder, required: true }); + } + return Array.from(found.values()); +} + +function sectionHeadings(text) { + const headings = []; + const lines = text.split('\n'); + let offset = 0; + let fence = null; + + lines.forEach((line, index) => { + if (fence) { + const close = /^(`{3,}|~{3,})[ \t]*$/.exec(line); + if (close && close[1][0] === fence.marker && close[1].length >= fence.length) { + fence = null; + } + } else { + const open = /^[ \t]*(`{3,}|~{3,})/.exec(line); + if (open) { + fence = { marker: open[1][0], length: open[1].length }; + } else { + const heading = /^##[ \t]+(.*)$/.exec(line); + if (heading) { + headings.push({ + heading: heading[1].trim(), + index: offset, + end: offset + line.length + (index < lines.length - 1 ? 1 : 0), + }); + } + } + } + offset += line.length + (index < lines.length - 1 ? 1 : 0); + }); + + return headings; +} + +function parsePrompts(source) { + const text = source.replace(/\r\n/g, '\n'); + // Prompt bodies are fenced text and may themselves contain heading-shaped lines. + // Only document headings outside fences can divide canonical sections. + const headings = sectionHeadings(text); + const prompts = []; + + headings.forEach((headingMatch, index) => { + const heading = headingMatch.heading; + const start = headingMatch.end; + const end = index + 1 < headings.length ? headings[index + 1].index : text.length; + const section = text.slice(start, end); + const textFenceCount = Array.from(section.matchAll(/^```text[ \t]*$/gm)).length; + const fences = Array.from( + section.matchAll(/^```text[ \t]*\n([\s\S]*?)^```[ \t]*(?:\n|$)/gm) + ); + + // A heading with no prompt body is structural navigation, not a prompt to serve. + if (textFenceCount === 0) return; + if (textFenceCount > 1) { + throw new Error( + `PROMPTS.md section "${heading}" has ${textFenceCount} text fences; expected one prompt body` + ); + } + if (fences.length !== 1) { + throw new Error(`PROMPTS.md section "${heading}" has an unclosed text prompt body`); + } + + const arrow = heading.indexOf('→'); + const commandPart = arrow === -1 ? '' : heading.slice(arrow + 1); + const commands = Array.from( + commandPart.matchAll(/`\/ratchet:([a-z-]+)`/g), + (match) => match[1] + ); + if (commands.length === 0) { + throw new Error(`PROMPTS.md section "${heading}" has a text body but no command arrow`); + } + + const title = heading.slice(0, arrow).trim(); + const aliases = commands.slice(1).map((name) => `/ratchet:${name}`); + const description = aliases.length + ? `${title}. Aliases: ${aliases.join(', ')}.` + : title; + const body = fences[0][1]; + prompts.push({ + name: commands[0], + title, + description, + arguments: promptArguments(body), + body, + }); + }); + + return prompts; +} + +// --- builder ------------------------------------------------------------------------- + +function buildPrompts() { + const prompts = parsePrompts(read(path.join('reference', 'PROMPTS.md'))); + return JSON.stringify({ + _trace: 'Traced by: openai-codex-gpt-5', + prompts, + }, null, 2) + '\n'; +} + +module.exports = { buildPrompts, OUT_REL, parsePrompts }; + +// CLI: default writes the artifact; --check fails (exit 1) if the committed file is stale, +// so the regenerate step can be verified outside the test runner too. +if (require.main === module) { + const generated = buildPrompts(); + const outPath = path.join(ROOT, OUT_REL); + if (process.argv.includes('--check')) { + // Normalize CRLF so autocrlf checks content rather than checkout line endings. + const current = fs.existsSync(outPath) + ? fs.readFileSync(outPath, 'utf8').replace(/\r\n/g, '\n') + : null; + if (current !== generated) { + process.stderr.write(`DRIFT: ${OUT_REL} is stale. Run: node scripts/prompts-gen.js\n`); + process.exit(1); + } + process.stdout.write(`ok ${OUT_REL} is in sync\n`); + } else { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, generated); + process.stdout.write(`wrote ${OUT_REL}\n`); + } +} diff --git a/src/mcp/prompts.generated.json b/src/mcp/prompts.generated.json new file mode 100644 index 0000000..6a0bbef --- /dev/null +++ b/src/mcp/prompts.generated.json @@ -0,0 +1,204 @@ +{ + "_trace": "Traced by: openai-codex-gpt-5", + "prompts": [ + { + "name": "ignite", + "title": "Master Ignition Prompt", + "description": "Master Ignition Prompt", + "arguments": [ + { + "name": "context", + "placeholder": "[PASTE CONTEXT]", + "required": true + } + ], + "body": "Operate as a consequence engine, not an advice engine.\n\nContext:\n\n[PASTE CONTEXT]\n\nYour job is to advance the state of the work.\n\nProcess:\n\n1. Lock the target:\n - literal object\n - desired outcome\n - proof-of-done\n - smallest useful artifact\n\n2. Run a friction auction:\n - identify the bottlenecks\n - rank by expected leverage\n - choose the one that matters most now\n\n3. Cut assumptions:\n - name the assumptions that could make this fail\n - select the top 3 to test or watch\n\n4. Build:\n - produce the smallest usable artifact immediately\n - no preamble\n - no generic advice\n\n5. Attack:\n - create a test harness that could embarrass the artifact\n - run the artifact through it\n\n6. Patch:\n - fix only what failed\n - preserve working parts\n - show the delta\n\n7. Verify:\n - run the harness against the artifact it is bound to\n - record the result as evidence, never as an opinion\n - on green, close the artifact; on red, patch again\n\n8. Compile:\n - decisions made\n - artifact created\n - unresolved risks\n - next action\n - next prompt to run\n\nRules:\n\n- If under-specified, infer the missing variable and proceed.\n- If multiple actions are possible, choose one.\n- If you are uncertain, expose the uncertainty and continue with the best safe assumption.\n- Do not praise the work.\n- Do not grade yourself.\n- Do not end in analysis.\n- End with an artifact and a next move.\n" + }, + { + "name": "lock", + "title": "1 · The Target Is Not The Topic", + "description": "1 · The Target Is Not The Topic", + "arguments": [ + { + "name": "context", + "placeholder": "[PASTE CONTEXT]", + "required": true + } + ], + "body": "The target is not the topic.\n\nGiven this context:\n\n[PASTE CONTEXT]\n\nExtract the actual target underneath it.\n\nReturn exactly:\n\n1. The literal object being worked on.\n2. The real outcome we are trying to cause.\n3. The proof that the outcome happened.\n4. The smallest artifact that would count as progress.\n5. The highest-information missing variable.\n6. Your chosen assumption if that variable is unavailable.\n7. The first move that creates irreversible progress.\n\nDo not brainstorm.\nDo not offer options unless forced.\nChoose the target and make it operational.\n" + }, + { + "name": "map", + "title": "The Fog Map", + "description": "The Fog Map", + "arguments": [ + { + "name": "context", + "placeholder": "[PASTE CONTEXT]", + "required": true + } + ], + "body": "Map the fog before you build.\n\nGiven this task and its codebase:\n\n[PASTE CONTEXT]\n\nDo not build yet. Scan the terrain first — existing, half-built, and reverted work —\nthen walk four quadrants in order, naming the current one:\n\n1. Known knowns — state the settled ground, each fact cited to file:line. Mark every\n assumption separately and say you will treat it as true until I correct it.\n2. Known unknowns — ask ONE question at a time, widest-impact first. Give lettered options\n with a recommended answer so I react in a few characters. Close each by:\n user answer | territory (you researched it, show question + finding) | probe (a time-boxed\n throwaway build whose only durable output is the finding — probe code dies, probe findings\n live) | OPEN (+ what unblocks it AND the route that will close it: ask-user, probe,\n park with owner, assumption + kill test, or defect).\n3. Unknown knowns — surface my tacit taste and context. Don't make me imagine it; put\n something concrete in front of me to react to (sample data, a throwaway mock, competing\n design directions). Record what each reaction reveals.\n4. Unknown unknowns — sweep for landmines: wrong-by-default data, stale denormalizations,\n unwritten conventions, prior attempts that were abandoned or rolled back (and the reason\n they died), latent bugs on this path. Each as a card: evidence (file:line) → why it bites\n → what it changes → decided | OPEN | sharp-edge.\n\nThen hand over ONE page: the four-quadrant map (OPEN items stay on the map, not in chat),\na tweakable build plan ordered by what might change, and a copy-paste implementation prompt.\n\nWhat you deliver is the map, not a build. Stop when I have it.\n" + }, + { + "name": "auction", + "title": "2 · The Friction Auction", + "description": "2 · The Friction Auction", + "arguments": [ + { + "name": "target", + "placeholder": "[TARGET]", + "required": true + } + ], + "body": "Run a friction auction on this target:\n\n[TARGET]\n\nList the obstacles that are actually preventing progress.\n\nFor each obstacle, score:\n\n- Leverage if removed: 1-10\n- Certainty that it matters: 1-10\n- Time-to-unblock: 1-10, where 10 means fast\n- Risk of ignoring it: 1-10\n\nCalculate Priority = Leverage × Certainty × Time-to-unblock × Risk.\n\nThen choose the single highest-priority obstacle.\n\nReturn:\n\n1. Ranked obstacle table.\n2. The winning obstacle.\n3. Why it beats the more tempting alternatives.\n4. The next artifact required to remove it.\n" + }, + { + "name": "cut", + "title": "3 · The Assumption Guillotine", + "description": "3 · The Assumption Guillotine", + "arguments": [], + "body": "Take this plan or target:\n\n[PASTE PLAN / TARGET]\n\nExtract the assumptions it depends on.\n\nFor each assumption, answer:\n\n- What breaks if this is false?\n- How would we notice quickly?\n- Can it be tested now?\n- What is the cheapest falsification test?\n\nThen select the 3 assumptions most likely to secretly kill the project.\n\nDesign a fast kill-test for each.\n\nNo reassurance.\nNo generic risk list.\nI want the assumptions that would make the whole thing stupid in hindsight.\n" + }, + { + "name": "mechanism", + "title": "4 · The Mechanism Knife", + "description": "4 · The Mechanism Knife", + "arguments": [ + { + "name": "situation", + "placeholder": "[PASTE SITUATION]", + "required": true + } + ], + "body": "Name the mechanism.\n\nGiven this situation:\n\n[PASTE SITUATION]\n\nSeparate:\n\n1. Surface symptoms.\n2. Underlying mechanism.\n3. The constraint causing the mechanism.\n4. The feedback loop keeping it alive.\n5. The intervention point with the highest leverage.\n6. The action that would change the mechanism, not merely describe it.\n\nUse one precise model.\nDo not give a taxonomy.\nDo not over-explain.\nMake the next move obvious.\n" + }, + { + "name": "attack", + "title": "5 · The Hostile Board", + "description": "5 · The Hostile Board", + "arguments": [ + { + "name": "artifact", + "placeholder": "[PASTE ARTIFACT]", + "required": true + } + ], + "body": "Convene a hostile board around this artifact:\n\n[PASTE ARTIFACT]\n\nThe board has five members:\n\n1. The Impatient User — attacks friction and confusion.\n2. The Competitor — attacks strategic weakness.\n3. The Maintainer — attacks future complexity.\n4. The Auditor — attacks evidence, assumptions, and compliance.\n5. The Saboteur — attacks the easiest way this fails in practice.\n\nEach member must provide:\n\n- Their strongest objection.\n- The concrete failure mode.\n- The evidence they would demand.\n- The smallest patch that would reduce the risk.\n\nAfter all five speak, synthesize only the patches worth making.\n\nDo not defend the artifact.\nImprove it.\n" + }, + { + "name": "build", + "title": "6 · Artifact Or Void", + "description": "6 · Artifact Or Void", + "arguments": [ + { + "name": "context", + "placeholder": "[PASTE CONTEXT]", + "required": true + }, + { + "name": "outcome", + "placeholder": "[PASTE OUTCOME]", + "required": true + } + ], + "body": "Produce the artifact now.\n\nContext:\n\n[PASTE CONTEXT]\n\nTarget outcome:\n\n[PASTE OUTCOME]\n\nRequired artifact type:\n\n[spec / prompt / checklist / test suite / spreadsheet schema / decision record / code patch / operating procedure]\n\nRules:\n\n- No preamble.\n- No \"here's how I would.\"\n- Build the smallest usable version.\n- It must be copy-pasteable.\n- Mark any holes explicitly as [HOLE: reason].\n- End with a 5-item test checklist for whether the artifact works.\n\nIf the artifact cannot be completed, produce the best v0 and expose the missing inputs.\n" + }, + { + "name": "verify", + "title": "7 · The Embarrassment Harness", + "description": "7 · The Embarrassment Harness", + "arguments": [ + { + "name": "artifact", + "placeholder": "[PASTE ARTIFACT]", + "required": true + } + ], + "body": "Do not grade the artifact.\n\nBuild a test harness that could embarrass it.\n\nArtifact:\n\n[PASTE ARTIFACT]\n\nCreate:\n\n1. Acceptance criteria.\n2. Happy-path test.\n3. Edge-case tests.\n4. Abuse/misuse tests.\n5. Ambiguity tests.\n6. Regression tests.\n7. Red flags that mean the artifact is fake-progress.\n\nThen run the artifact through the harness.\n\nReturn:\n\n- Passes.\n- Failures.\n- Severity.\n- Required patch.\n- Whether the artifact is usable despite failures.\n" + }, + { + "name": "patch", + "title": "8 · The Delta Surgeon", + "description": "8 · The Delta Surgeon", + "arguments": [ + { + "name": "artifact", + "placeholder": "[PASTE ARTIFACT]", + "required": true + }, + { + "name": "failures", + "placeholder": "[PASTE FAILURES]", + "required": true + } + ], + "body": "Patch only what failed.\n\nArtifact:\n\n[PASTE ARTIFACT]\n\nFailures:\n\n[PASTE FAILURES]\n\nRules:\n\n- Do not expand the scope.\n- Do not rewrite working parts for elegance.\n- Preserve the original intent.\n- Make the smallest safe patch.\n- Show changes under: REMOVE / ADD / CHANGE.\n- Then rerun the relevant tests only.\n\nReturn the patched artifact and remaining defects.\n" + }, + { + "name": "decide", + "title": "9 · The Decision Spike", + "description": "9 · The Decision Spike", + "arguments": [ + { + "name": "context", + "placeholder": "[PASTE CONTEXT]", + "required": true + }, + { + "name": "options", + "placeholder": "[PASTE OPTIONS]", + "required": true + } + ], + "body": "Make the decision.\n\nDecision context:\n\n[PASTE CONTEXT]\n\nOptions under consideration:\n\n[PASTE OPTIONS]\n\nJudge by:\n\n1. Expected upside.\n2. Reversibility.\n3. Time-to-feedback.\n4. Strategic compounding.\n5. Cost of being wrong.\n6. What this unlocks next.\n\nReturn:\n\n- The chosen option.\n- The rejected option that was most tempting.\n- Why it loses.\n- The first action.\n- The tripwire that would make us reverse the decision.\n\nNo balanced overview.\nPick.\n" + }, + { + "name": "burn", + "title": "10 · The Option Burn", + "description": "10 · The Option Burn", + "arguments": [ + { + "name": "state", + "placeholder": "[PASTE STATE]", + "required": true + } + ], + "body": "We are carrying too many options.\n\nGiven this project state:\n\n[PASTE STATE]\n\nIdentify the options we should deliberately stop preserving.\n\nFor each option:\n\n- Why it is attractive.\n- Why it is stealing energy.\n- What fear keeps it alive.\n- What becomes simpler if we kill it.\n- Whether to kill, park, or keep.\n\nThen name the one option to burn now and the exact sentence I should use to record that decision.\n" + }, + { + "name": "compile", + "title": "11 · The State Compiler", + "description": "11 · The State Compiler", + "arguments": [], + "body": "Compile this session into durable state.\n\nSession content:\n\n[PASTE SESSION / NOTES / OUTPUTS]\n\nReturn a compact record with:\n\n1. Working title.\n2. Current objective.\n3. Decisions made.\n4. Artifacts created.\n5. Defects or risks found.\n6. Open loops.\n7. Next action.\n8. Next prompt to run.\n9. Retrieval tags.\n10. One-sentence memory worth saving.\n\nOnly include information that changes future behavior.\nDelete interesting-but-nonoperative commentary.\n" + }, + { + "name": "push", + "title": "12 · The Boundary Push", + "description": "12 · The Boundary Push", + "arguments": [], + "body": "Now push the boundary.\n\nCurrent artifact or plan:\n\n[PASTE ARTIFACT / PLAN]\n\nFind the boldest adjacent move that could multiply the result without turning it into fantasy.\n\nReturn:\n\n1. The safe default path.\n2. The boundary-pushing path.\n3. Why the boundary path might work.\n4. Why it might be stupid.\n5. The cheapest experiment that distinguishes them.\n6. The version we should try.\n7. The first irreversible move.\n\nDo not be reckless.\nDo not be timid.\nFind the edge where information gain is highest.\n" + }, + { + "name": "repo-audit", + "title": "Specialized · Software QA agent", + "description": "Specialized · Software QA agent. Aliases: /ratchet:qa-ledger.", + "arguments": [], + "body": "Turn this codebase into a quality ledger.\n\nGiven access to the repository or file tree:\n\n[PASTE TREE / CONTEXT]\n\nDiscover user-facing features by evidence, not guesses.\n\nFor each feature, create:\n\n- Feature ID\n- Feature name\n- User story\n- Evidence in code\n- Expected behavior\n- Edge cases\n- Test cases\n- Dependencies\n- Known assumptions\n- Current risk level\n\nThen identify:\n\n1. Screens/routes/workflows not yet documented.\n2. APIs or state transitions that need tests.\n3. The highest-risk feature.\n4. The first test suite to write.\n5. The smallest fix likely to improve user trust.\n\nDo not declare coverage complete unless every route, screen, workflow, and API interaction has evidence.\n" + }, + { + "name": "prompt-audit", + "title": "Specialized · Prompt-library evolution", + "description": "Specialized · Prompt-library evolution", + "arguments": [ + { + "name": "prompts", + "placeholder": "[PASTE PROMPTS]", + "required": true + } + ], + "body": "Audit this prompt library as an operating system.\n\nPrompt list:\n\n[PASTE PROMPTS]\n\nDo not rewrite them yet.\n\nFirst classify each prompt by the type of work it performs:\n\n- target locking\n- diagnosis\n- artifact production\n- adversarial testing\n- decision\n- patching\n- memory/state\n- orchestration\n- compression\n- boundary pushing\n\nThen find:\n\n1. Overrepresented moves.\n2. Missing moves.\n3. Prompts that cause meta-looping.\n4. Prompts that create artifacts.\n5. Prompts that should be deleted or merged.\n6. The minimum new prompt set that would outperform the library.\n\nFinally, produce the upgraded library as a sequenced workflow, not a pile of prompts.\n" + } + ] +} diff --git a/src/mcp/prompts.js b/src/mcp/prompts.js new file mode 100644 index 0000000..e20369c --- /dev/null +++ b/src/mcp/prompts.js @@ -0,0 +1,52 @@ +'use strict'; + +// Canonical MCP prompts are generated from reference/PROMPTS.md before they reach runtime. +// Traced by: openai-codex-gpt-5 + +const registry = require('./prompts.generated.json'); +const rpc = require('./rpc'); + +function list() { + return { + prompts: registry.prompts.map((entry) => { + // The generated JSON is cached by require(); deep-copy before crossing the boundary. + const prompt = structuredClone(entry); + delete prompt.body; + for (const argument of prompt.arguments) delete argument.placeholder; + return prompt; + }), + }; +} + +function get(name, args_) { + if (typeof name !== 'string') { + throw rpc.rpcError(-32602, 'prompts/get requires a prompt name'); + } + const found = registry.prompts.find((entry) => entry.name === name); + if (!found) throw rpc.rpcError(-32602, `unknown prompt: ${name}`); + const prompt = structuredClone(found); + + const args = args_ && typeof args_ === 'object' && !Array.isArray(args_) ? args_ : {}; + let body = prompt.body; + for (const argument of prompt.arguments) { + const value = args[argument.name]; + if (value === undefined) { + throw rpc.rpcError(-32602, `missing required prompt argument: ${argument.name}`); + } + if (typeof value !== 'string') { + throw rpc.rpcError(-32602, `prompt argument ${argument.name} must be a string`); + } + // split/join inserts client text literally; replacement syntax such as $& stays data. + body = body.split(argument.placeholder).join(value); + } + + return { + description: prompt.description, + messages: [{ + role: 'user', + content: { type: 'text', text: body }, + }], + }; +} + +module.exports = { get, list }; diff --git a/src/mcp/server.js b/src/mcp/server.js index a7f22d4..aee0908 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -22,6 +22,7 @@ // Traced by: openai-codex-gpt-5 const handles = require('./handles'); +const prompts = require('./prompts'); const repository = require('./repository'); const rpc = require('./rpc'); const workspace = require('./workspace'); @@ -156,7 +157,11 @@ function createServer(options) { name: (opts.serverInfo && opts.serverInfo.name) || 'torque-mcp', version: (opts.serverInfo && opts.serverInfo.version) || pkg.version, }); - const capabilities = Object.freeze({ tools: Object.freeze({}), resources: Object.freeze({}) }); + const capabilities = Object.freeze({ + tools: Object.freeze({}), + resources: Object.freeze({}), + prompts: Object.freeze({}), + }); function createConnection(options_) { const connectionOptions = options_ || {}; @@ -331,6 +336,17 @@ function createServer(options) { eras: ['modern', 'legacy'], handler: readResource, }, + 'prompts/list': { + eras: ['modern', 'legacy'], + handler: (params, context) => { + listParams(params, 'prompts/list'); + return withCache(prompts.list(), context.era, LIST_TTL_MS, 'public'); + }, + }, + 'prompts/get': { + eras: ['modern', 'legacy'], + handler: (params) => prompts.get(params.name, params.arguments), + }, }; const protocol = rpc.createKernel({ diff --git a/test/mcp-prompts.test.js b/test/mcp-prompts.test.js new file mode 100644 index 0000000..400afa1 --- /dev/null +++ b/test/mcp-prompts.test.js @@ -0,0 +1,404 @@ +'use strict'; + +// Torque MCP build-order step 3a: the canonical prompts as an MCP prompt surface. +// Run: node test/mcp-prompts.test.js +// +// The invariant this suite defends: +// THE WIRE SURFACE IS A READOUT OF reference/PROMPTS.md, NOT A SECOND COPY OF IT. +// The prompts are generated at build time (scripts/prompts-gen.js) and the +// committed artifact is byte-matched by plugin-shape, so a prompt cannot be +// edited into the server without editing the source it claims to serve. What is +// proven here is the other half: the generator refuses an ambiguous section +// instead of quietly dropping it, and the runtime refuses an unfilled template +// instead of shipping a prompt with a hole in it. +// +// Written RED first against an absent module. +// Traced by: claude-opus-5 +// Traced by: openai-codex-gpt-5 + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const tmp = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), 'ratchet-mcp-prompts-test-')) +); +process.env.RATCHET_DATA_DIR = path.join(tmp, 'state'); +process.env.RATCHET_EVOLVE_LOG = path.join(tmp, 'evolve-log.jsonl'); + +const promptsGen = require('../scripts/prompts-gen'); +const prompts = require('../src/mcp/prompts'); +const mcp = require('../src/mcp/server'); + +const META = 'io.modelcontextprotocol/'; +const MODERN = '2026-07-28'; +const LEGACY = '2025-11-25'; + +// The 16 commands PROMPTS.md declares, written out rather than re-derived: a test +// that parses the source the same way the generator does would pass on the same +// mistake twice (convention 14). +const EXPECTED = [ + 'ignite', 'lock', 'map', 'auction', 'cut', 'mechanism', 'attack', 'build', + 'verify', 'patch', 'decide', 'burn', 'compile', 'push', 'repo-audit', 'prompt-audit', +]; + +let passed = 0; +const failures = []; +function ok(name, fn) { + try { + fn(); + passed++; + process.stdout.write(` ok ${name}\n`); + } catch (e) { + failures.push(name); + process.stdout.write(` FAIL ${name}\n ${e && e.message ? e.message : e}\n`); + } +} + +// A refusal is only evidence if it is the refusal we asked for: a test that +// accepts any throw passes when the module breaks for an unrelated reason. +function refuses(fn, code, needle, why) { + let err = null; + try { + fn(); + } catch (e) { + err = e; + } + assert.ok(err, `${why} — expected a refusal, got none`); + assert.ok(err.rpc, `${why} — refused without an rpc code: ${err.message}`); + assert.strictEqual(err.rpc.code, code, `${why} — refused with ${err.rpc.code}: ${err.message}`); + assert.ok(err.message.includes(needle), `${why} — refused for the wrong reason: ${err.message}`); + return err; +} + +let fixtureNumber = 0; +function fixture(label) { + const dir = path.join(tmp, `${label}-${fixtureNumber++}`); + fs.mkdirSync(dir, { recursive: true }); + return fs.realpathSync.native(dir); +} + +function service(label) { + return mcp.createServer({ + roots: [fixture(label)], + serverInfo: { name: 'torque-mcp-test', version: '0.0.0' }, + }); +} + +let requestId = 0; +function modern(conn, method, params) { + return conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method, + params: { + ...(params || {}), + _meta: { + [META + 'protocolVersion']: MODERN, + [META + 'clientCapabilities']: {}, + [META + 'clientInfo']: { name: 'prompts-client', version: '0' }, + }, + }, + }); +} + +function legacyConnection(label) { + const conn = service(label).createConnection({}); + const init = conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method: 'initialize', + params: { + protocolVersion: LEGACY, + capabilities: {}, + clientInfo: { name: 'prompts-client', version: '0' }, + }, + }); + assert.strictEqual(init.error, undefined, 'the legacy handshake must succeed'); + return conn; +} + +function legacy(conn, method, params) { + return conn.handleMessage({ + jsonrpc: '2.0', + id: ++requestId, + method, + params: params || {}, + }); +} + +function answer(response) { + assert.strictEqual(response.error, undefined, response.error && response.error.message); + return response.result; +} + +// --------------------------------------------------------------------------- +// Coverage: what the source declares is exactly what the surface serves. +// --------------------------------------------------------------------------- + +ok('P1 every prompt section in PROMPTS.md becomes a prompt, and the map table does not', () => { + const listed = prompts.list().prompts; + assert.deepStrictEqual(listed.map((p) => p.name), EXPECTED, + 'the served set is the 16 declared commands, in source order'); + + // The map table is a `## ` section too. It carries no prompt body, so serving + // it would mean serving a table of contents as if it were a prompt. + for (const p of listed) { + assert.ok(!/Command .. prompt map/.test(p.title || ''), + `the command↔prompt map is not a prompt: ${p.title}`); + assert.ok(p.body === undefined, 'list() advertises prompts, it does not ship their bodies'); + } +}); + +ok('P2 the Software QA prompt keeps its second command as an alias, not a second prompt', () => { + // One section, two commands. The extra command has to go somewhere visible or + // /ratchet:qa-ledger silently stops having a canonical prompt on the wire. + const listed = prompts.list().prompts; + const audit = listed.find((p) => p.name === 'repo-audit'); + assert.ok(audit, 'repo-audit is served'); + assert.ok(audit.description.includes('/ratchet:qa-ledger'), + `the alias command is named in the description: ${audit.description}`); + assert.strictEqual(listed.filter((p) => p.name === 'qa-ledger').length, 0, + 'one section is one prompt, even when two commands implement it'); +}); + +ok('P3 arguments are derived from the placeholders actually in the body', () => { + const byName = new Map(prompts.list().prompts.map((p) => [p.name, p])); + assert.deepStrictEqual(byName.get('lock').arguments.map((a) => a.name), ['context']); + assert.deepStrictEqual(byName.get('auction').arguments.map((a) => a.name), ['target']); + assert.deepStrictEqual(byName.get('patch').arguments.map((a) => a.name), ['artifact', 'failures']); + assert.deepStrictEqual(byName.get('build').arguments.map((a) => a.name), ['context', 'outcome']); + for (const p of prompts.list().prompts) { + for (const arg of p.arguments) { + assert.strictEqual(arg.required, true, `${p.name}.${arg.name} is required`); + } + } +}); + +// --------------------------------------------------------------------------- +// The wire surface. +// --------------------------------------------------------------------------- + +ok('P4 prompts/list is deterministic and identical across two connections', () => { + const server = service('p4'); + const a = answer(modern(server.createConnection({}), 'prompts/list')); + const b = answer(modern(server.createConnection({}), 'prompts/list')); + assert.deepStrictEqual(a, b, 'a second connection sees the same list'); + assert.strictEqual(JSON.stringify(a), JSON.stringify(b), 'byte-for-byte, including order'); + assert.strictEqual(a.prompts.length, EXPECTED.length); +}); + +ok('P5 prompts/get substitutes the supplied argument and leaves no placeholder behind', () => { + const conn = service('p5').createConnection({}); + const got = answer(modern(conn, 'prompts/get', { + name: 'lock', + arguments: { context: 'the release branch is red' }, + })); + const text = got.messages[0].content.text; + assert.strictEqual(got.messages[0].role, 'user'); + assert.strictEqual(got.messages[0].content.type, 'text'); + assert.ok(text.includes('the release branch is red'), 'the value landed in the body'); + assert.ok(!text.includes('[PASTE CONTEXT]'), 'the placeholder is gone'); + assert.strictEqual(text.indexOf('['), -1, + `no bracketed placeholder survives in a fully-argumented prompt: ${text}`); +}); + +ok('P6 an argument value is inserted literally, never read as a replacement pattern', () => { + // The reason substitution is split/join and not String.replace: `$&` and `$1` + // in a client-supplied value would otherwise rewrite themselves. + const conn = service('p6').createConnection({}); + const value = 'cost $5 for $& and $1 and $`'; + const text = answer(modern(conn, 'prompts/get', { + name: 'lock', arguments: { context: value }, + })).messages[0].content.text; + assert.ok(text.includes(value), `the value survives verbatim: ${text}`); +}); + +ok('P7 an unknown prompt name is refused', () => { + const conn = service('p7').createConnection({}); + const res = modern(conn, 'prompts/get', { name: 'not-a-prompt', arguments: {} }); + assert.ok(res.error, `expected a refusal, got ${JSON.stringify(res.result)}`); + assert.strictEqual(res.error.code, -32602); + assert.ok(/unknown prompt/.test(res.error.message), res.error.message); + + refuses(() => prompts.get('not-a-prompt', {}), -32602, 'unknown prompt', + 'the module refuses on its own, not only behind the server'); + refuses(() => prompts.get(undefined, {}), -32602, 'prompt name', + 'a missing name is refused as a missing name, not as an unknown one'); +}); + +ok('P8 a missing required argument is refused', () => { + const conn = service('p8').createConnection({}); + const res = modern(conn, 'prompts/get', { name: 'lock', arguments: {} }); + assert.ok(res.error, `expected a refusal, got ${JSON.stringify(res.result)}`); + assert.strictEqual(res.error.code, -32602); + assert.ok(/context/.test(res.error.message), res.error.message); + + refuses(() => prompts.get('lock', undefined), -32602, 'context', + 'no arguments object at all is still a missing argument'); + refuses(() => prompts.get('patch', { artifact: 'a' }), -32602, 'failures', + 'the refusal names the argument that is missing, not the first one'); +}); + +ok('P9 a non-string argument is refused', () => { + const conn = service('p9').createConnection({}); + for (const value of [7, null, ['a'], { text: 'a' }, true]) { + const res = modern(conn, 'prompts/get', { name: 'lock', arguments: { context: value } }); + assert.ok(res.error, `${JSON.stringify(value)} must be refused, got ${JSON.stringify(res.result)}`); + assert.strictEqual(res.error.code, -32602, res.error.message); + assert.ok(/context/.test(res.error.message), res.error.message); + } +}); + +ok('P10 a returned prompt is a copy, so a caller cannot edit the registry', () => { + const first = prompts.get('lock', { context: 'one' }); + first.messages[0].content.text = 'poisoned'; + first.messages.push({ role: 'user', content: { type: 'text', text: 'extra' } }); + first.description = 'poisoned'; + const second = prompts.get('lock', { context: 'two' }); + assert.strictEqual(second.messages.length, 1, 'the registry did not grow a message'); + assert.ok(second.messages[0].content.text.includes('two'), 'the second read is its own body'); + assert.ok(!second.messages[0].content.text.includes('poisoned'), second.messages[0].content.text); + assert.ok(!/poisoned/.test(second.description), second.description); + + const listedOnce = prompts.list(); + listedOnce.prompts[0].name = 'poisoned'; + listedOnce.prompts[0].arguments.push({ name: 'poisoned', required: true }); + listedOnce.prompts.length = 1; + const listedTwice = prompts.list(); + assert.deepStrictEqual(listedTwice.prompts.map((p) => p.name), EXPECTED, + 'the advertised list is rebuilt, not handed out'); + assert.ok(!listedTwice.prompts[0].arguments.some((a) => a.name === 'poisoned'), + 'a returned argument array cannot mutate the registry'); + assert.deepStrictEqual( + listedTwice.prompts[0].arguments.map((a) => a.name), + prompts.list().prompts[0].arguments.map((a) => a.name), + 'the argument arrays are copies too' + ); +}); + +ok('P11 the modern era carries the list cache fields and the legacy era omits them', () => { + const modernResult = answer(modern(service('p11m').createConnection({}), 'prompts/list')); + assert.strictEqual(modernResult.ttlMs, 300000); + assert.strictEqual(modernResult.cacheScope, 'public'); + + const legacyResult = answer(legacy(legacyConnection('p11l'), 'prompts/list')); + assert.strictEqual(legacyResult.ttlMs, undefined, 'legacy has no cache contract to state'); + assert.strictEqual(legacyResult.cacheScope, undefined); + assert.strictEqual(legacyResult.prompts.length, EXPECTED.length, + 'both eras serve the same prompts'); +}); + +ok('P12 prompts/list has one fixed page, and prompts is an advertised capability', () => { + const conn = service('p12').createConnection({}); + const paged = modern(conn, 'prompts/list', { cursor: 'abc' }); + assert.ok(paged.error, 'a cursor is refused like every other fixed list'); + assert.strictEqual(paged.error.code, -32602); + + const discover = conn.handleMessage({ jsonrpc: '2.0', id: ++requestId, method: 'server/discover' }); + assert.deepStrictEqual(discover.result.capabilities, { tools: {}, resources: {}, prompts: {} }, + 'a client that cannot see the capability will never ask for the prompts'); +}); + +ok('P13 a legacy connection can get a prompt too', () => { + const conn = legacyConnection('p13'); + const text = answer(legacy(conn, 'prompts/get', { + name: 'attack', arguments: { artifact: 'the receipt renderer' }, + })).messages[0].content.text; + assert.ok(text.includes('the receipt renderer'), text); +}); + +// --------------------------------------------------------------------------- +// The generator fails loudly, so a drifted source can never silently shrink +// the surface. +// --------------------------------------------------------------------------- + +ok('G1 a prompt section with no command arrow makes the generator throw', () => { + const source = [ + '# fixture', + '', + '## Command ↔ prompt map', + '', + '| Command | Canonical prompt |', + '| --- | --- |', + '| `/ratchet:lock` | 1 · The Target Is Not The Topic |', + '', + '## 1 · The Target Is Not The Topic → `/ratchet:lock`', + '', + '```text', + 'Lock [PASTE CONTEXT].', + '```', + '', + '## An Orphan Prompt', + '', + '```text', + 'Do the thing.', + '```', + '', + ].join('\n'); + assert.throws(() => promptsGen.parsePrompts(source), /An Orphan Prompt/, + 'a body with no command must name itself in the failure, not be skipped'); +}); + +ok('G2 a section with two prompt bodies makes the generator throw', () => { + const source = [ + '## 1 · Double → `/ratchet:lock`', + '', + '```text', + 'first', + '```', + '', + '```text', + 'second', + '```', + '', + ].join('\n'); + assert.throws(() => promptsGen.parsePrompts(source), /two|2|one prompt body/i, + 'which of the two is the prompt is not a guess the generator gets to make'); +}); + +ok('G3 a heading-shaped line inside a text fence stays in the prompt body', () => { + const source = [ + '## Embedded heading → `/ratchet:lock`', + '', + '```text', + 'Keep this line.', + '## This is prompt text, not a document section', + 'Keep this too.', + '```', + '', + ].join('\n'); + const parsed = promptsGen.parsePrompts(source); + assert.strictEqual(parsed.length, 1, 'a body heading cannot silently delete its prompt'); + assert.ok(parsed[0].body.includes('## This is prompt text, not a document section')); +}); + +ok('G4 the generator parses the real source and only skips bodiless sections', () => { + const parsed = promptsGen.parsePrompts(fs.readFileSync( + path.join(__dirname, '..', 'reference', 'PROMPTS.md'), 'utf8' + )); + assert.deepStrictEqual(parsed.map((p) => p.name), EXPECTED); + for (const p of parsed) { + assert.ok(p.body.trim().length > 40, `${p.name} carries a real body`); + assert.ok(!/^\s*```/.test(p.body), `${p.name} body excludes its fence`); + } +}); + +ok('G5 the generated artifact is CRLF-insensitive and byte-stable', () => { + const once = promptsGen.buildPrompts(); + const twice = promptsGen.buildPrompts(); + assert.strictEqual(once, twice, 'two generations agree'); + assert.strictEqual(once.indexOf('\r'), -1, 'the artifact is \\n only'); + const source = fs.readFileSync( + path.join(__dirname, '..', 'reference', 'PROMPTS.md'), 'utf8' + ).replace(/\n/g, '\r\n'); + assert.deepStrictEqual( + promptsGen.parsePrompts(source).map((p) => p.name), + EXPECTED, + 'a CRLF checkout parses the same sections (convention 16)' + ); +}); + +process.stdout.write(`\n${passed} passed, ${failures.length} failed\n`); +if (failures.length) process.exit(1); diff --git a/test/mcp-server.test.js b/test/mcp-server.test.js index 4085c51..36290e3 100644 --- a/test/mcp-server.test.js +++ b/test/mcp-server.test.js @@ -141,7 +141,7 @@ function payload(response) { return fromText; } -ok('S1 both protocol eras advertise the fixed tools and resources capabilities', () => { +ok('S1 both protocol eras advertise the fixed tools, resources, and prompts capabilities', () => { const root = fixture('s1-root'); const server = service([root]); @@ -149,11 +149,11 @@ ok('S1 both protocol eras advertise the fixed tools and resources capabilities', const discover = modernConn.handleMessage({ jsonrpc: '2.0', id: ++requestId, method: 'server/discover', }); - assert.deepStrictEqual(discover.result.capabilities, { tools: {}, resources: {} }); + assert.deepStrictEqual(discover.result.capabilities, { tools: {}, resources: {}, prompts: {} }); const legacyConn = server.createConnection(); const init = initialize(legacyConn); - assert.deepStrictEqual(init.result.capabilities, { tools: {}, resources: {} }); + assert.deepStrictEqual(init.result.capabilities, { tools: {}, resources: {}, prompts: {} }); }); ok('S2 tools/list exposes only workspace.open with a closed path schema', () => { diff --git a/test/plugin-shape.test.js b/test/plugin-shape.test.js index 6ca2567..98a8f22 100644 --- a/test/plugin-shape.test.js +++ b/test/plugin-shape.test.js @@ -406,6 +406,30 @@ ok('the skill graph covers every skill and rebuilds cleanly', () => { ); }); +ok('the MCP prompt surface is derived from PROMPTS.md, not remembered (drift guard)', () => { + // src/mcp/prompts.generated.json is what the MCP server serves as prompts/list and + // prompts/get. Committing it is only safe because it is DERIVED: scripts/prompts-gen.js + // reads reference/PROMPTS.md and emits it. Hand-edit the JSON and the wire would teach a + // client a prompt the canonical source never said — the exact drift PROMPTS.md exists to + // outrank. So byte-match it against a fresh generation, normalizing CRLF only so a clone + // whose autocrlf touched the file still checks content (convention 16). + const promptsGen = require('../scripts/prompts-gen'); + assert.ok(exists(promptsGen.OUT_REL), 'src/mcp/prompts.generated.json exists'); + const committed = read(promptsGen.OUT_REL).replace(/\r\n/g, '\n'); + assert.strictEqual( + committed, + promptsGen.buildPrompts(), + 'src/mcp/prompts.generated.json is stale — run: node scripts/prompts-gen.js' + ); + const generated = JSON.parse(committed); + assert.ok(Array.isArray(generated.prompts) && generated.prompts.length, + 'the generated prompt set is non-empty — an empty surface is drift, not a clean slate'); + for (const entry of generated.prompts) { + assert.ok(entry.name, `a generated prompt has a name: ${JSON.stringify(entry)}`); + assert.ok(entry.body && entry.body.trim(), `prompt "${entry.name}" has a body`); + } +}); + ok('the graph README parks the aperture cross-links instead of smuggling them in', () => { // The honest-scope boundary is load-bearing: the derived graph deliberately omits the // aperture cross-links (a separate repo, never adversarially attacked) and documents that