diff --git a/scripts/construct.mjs b/scripts/construct.mjs index e38bc90..db4837b 100755 --- a/scripts/construct.mjs +++ b/scripts/construct.mjs @@ -3968,7 +3968,8 @@ function analyzeRun(runDir) { const brief = loadBrief(runDir); const evidence = loadEvidence3(runDir); const notes = loadMetaNotes(runDir); - const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q "${q.replace(/"/g, "'")}"`; + const shellQuote = (s) => `'${s.replace(/\r\n|[\r\n]/g, " ").replace(/'/g, `'"'"'`)}'`; + const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q ${shellQuote(q)}`; const bySource = {}; for (const e of evidence) bySource[e.source] = (bySource[e.source] ?? 0) + 1; if (evidence.length === 0) { @@ -4426,7 +4427,8 @@ function agentContracts(runAbs, engineAbs, idea) { runAbs, "Your ONE sanctioned write surface is your own isolated git worktree \u2014 app code and app tests only. The run folder (BUILD-PLAN.json, SRD.json, evidence/) stays the orchestrator's." ); - const product = idea ? `\`${idea}\`` : "(no brief.json yet \u2014 the orchestrator will restate the one-liner in your prompt)"; + const inlineIdea = idea.replace(/`/g, "'").replace(/\s*(?:\r\n|[\r\n])\s*/g, " ").trim(); + const product = inlineIdea ? `\`${inlineIdea}\`` : "(no brief.json yet \u2014 the orchestrator will restate the one-liner in your prompt)"; return { researcher: `# Contract: researcher @@ -4446,7 +4448,7 @@ ${footer}`, You are an adversarial skeptic verifying that each SRD claim is actually SUPPORTED by the evidence it cites (references/orchestration.md Pattern 4). Assume the citation is decorative until the evidence proves otherwise. -Worklist: \`${join15(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). +Worklist: \`${join15(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). If a PAIRS key is no longer in the worklist, skip it and say so in your note. For EACH of your pairs: @@ -4477,7 +4479,7 @@ Return (structured output): \`{ "lens", "score", "rationale" }\` \u2014 a 1\u201 ${footer}`, builder: `# Contract: builder -You build ONE task of \`${join15(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). +You build ONE task of \`${join15(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). If your TASK id is no longer in the worklist, skip it and say so in your summary. 1. Read your task in the plan. Its \`acceptance\` entries POINT into \`${join15(runAbs, "SRD.json")}\` (\`functional[frId].acceptance[index]\`) \u2014 the SRD stays the single source of truth for what "done" means. 2. Work ONLY inside your own git worktree (the workflow dispatches you with \`isolation: 'worktree'\`). TDD each acceptance criterion: failing test first, then make it pass \u2014 and **every test names its FR id** (e.g. \`describe("FR-001 \u2026")\`; that is what \`verify\` greps for). diff --git a/skills/construct/scripts/construct.mjs b/skills/construct/scripts/construct.mjs index e38bc90..db4837b 100755 --- a/skills/construct/scripts/construct.mjs +++ b/skills/construct/scripts/construct.mjs @@ -3968,7 +3968,8 @@ function analyzeRun(runDir) { const brief = loadBrief(runDir); const evidence = loadEvidence3(runDir); const notes = loadMetaNotes(runDir); - const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q "${q.replace(/"/g, "'")}"`; + const shellQuote = (s) => `'${s.replace(/\r\n|[\r\n]/g, " ").replace(/'/g, `'"'"'`)}'`; + const drill = (cmd, q) => `construct ${cmd} --out ${runDir} --q ${shellQuote(q)}`; const bySource = {}; for (const e of evidence) bySource[e.source] = (bySource[e.source] ?? 0) + 1; if (evidence.length === 0) { @@ -4426,7 +4427,8 @@ function agentContracts(runAbs, engineAbs, idea) { runAbs, "Your ONE sanctioned write surface is your own isolated git worktree \u2014 app code and app tests only. The run folder (BUILD-PLAN.json, SRD.json, evidence/) stays the orchestrator's." ); - const product = idea ? `\`${idea}\`` : "(no brief.json yet \u2014 the orchestrator will restate the one-liner in your prompt)"; + const inlineIdea = idea.replace(/`/g, "'").replace(/\s*(?:\r\n|[\r\n])\s*/g, " ").trim(); + const product = inlineIdea ? `\`${inlineIdea}\`` : "(no brief.json yet \u2014 the orchestrator will restate the one-liner in your prompt)"; return { researcher: `# Contract: researcher @@ -4446,7 +4448,7 @@ ${footer}`, You are an adversarial skeptic verifying that each SRD claim is actually SUPPORTED by the evidence it cites (references/orchestration.md Pattern 4). Assume the citation is decorative until the evidence proves otherwise. -Worklist: \`${join15(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). +Worklist: \`${join15(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). If a PAIRS key is no longer in the worklist, skip it and say so in your note. For EACH of your pairs: @@ -4477,7 +4479,7 @@ Return (structured output): \`{ "lens", "score", "rationale" }\` \u2014 a 1\u201 ${footer}`, builder: `# Contract: builder -You build ONE task of \`${join15(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). +You build ONE task of \`${join15(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). If your TASK id is no longer in the worklist, skip it and say so in your summary. 1. Read your task in the plan. Its \`acceptance\` entries POINT into \`${join15(runAbs, "SRD.json")}\` (\`functional[frId].acceptance[index]\`) \u2014 the SRD stays the single source of truth for what "done" means. 2. Work ONLY inside your own git worktree (the workflow dispatches you with \`isolation: 'worktree'\`). TDD each acceptance criterion: failing test first, then make it pass \u2014 and **every test names its FR id** (e.g. \`describe("FR-001 \u2026")\`; that is what \`verify\` greps for). diff --git a/src/analyze.ts b/src/analyze.ts index 34f6d9d..afbae7e 100644 --- a/src/analyze.ts +++ b/src/analyze.ts @@ -45,7 +45,12 @@ export function analyzeRun(runDir: string): GapReport { const brief = loadBrief(runDir); const evidence = loadEvidence(runDir); const notes = loadMetaNotes(runDir); - const drill = (cmd: string, q: string) => `construct ${cmd} --out ${runDir} --q "${q.replace(/"/g, "'")}"`; + // Drill commands embed brief free-text into a copy-paste / subagent-executed + // shell line: single-quote the query (the only shell-inert quoting — backticks + // and $() never expand inside single quotes), escape an embedded ' as '"'"' + // and flatten newlines, so a hostile brief field cannot inject. + const shellQuote = (s: string) => `'${s.replace(/\r\n|[\r\n]/g, " ").replace(/'/g, `'"'"'`)}'`; + const drill = (cmd: string, q: string) => `construct ${cmd} --out ${runDir} --q ${shellQuote(q)}`; const bySource: Record = {}; for (const e of evidence) bySource[e.source] = (bySource[e.source] ?? 0) + 1; diff --git a/src/orchestrate-templates.ts b/src/orchestrate-templates.ts index 0aad699..f86c4ff 100644 --- a/src/orchestrate-templates.ts +++ b/src/orchestrate-templates.ts @@ -242,7 +242,14 @@ export function agentContracts(runAbs: string, engineAbs: string, idea: string): runAbs, "Your ONE sanctioned write surface is your own isolated git worktree — app code and app tests only. The run folder (BUILD-PLAN.json, SRD.json, evidence/) stays the orchestrator's.", ); - const product = idea ? `\`${idea}\`` : "(no brief.json yet — the orchestrator will restate the one-liner in your prompt)"; + // The idea is interpolated into a Markdown inline-code span: an interior + // backtick would close the span early (and read as executable in copy-paste + // contexts) — strip backticks to apostrophes and flatten newlines. + const inlineIdea = idea + .replace(/`/g, "'") + .replace(/\s*(?:\r\n|[\r\n])\s*/g, " ") + .trim(); + const product = inlineIdea ? `\`${inlineIdea}\`` : "(no brief.json yet — the orchestrator will restate the one-liner in your prompt)"; return { researcher: `# Contract: researcher @@ -262,7 +269,7 @@ ${footer}`, You are an adversarial skeptic verifying that each SRD claim is actually SUPPORTED by the evidence it cites (references/orchestration.md Pattern 4). Assume the citation is decorative until the evidence proves otherwise. -Worklist: \`${join(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). +Worklist: \`${join(runAbs, "VERIFY.todo.json")}\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). If a PAIRS key is no longer in the worklist, skip it and say so in your note. For EACH of your pairs: @@ -293,7 +300,7 @@ Return (structured output): \`{ "lens", "score", "rationale" }\` — a 1–5 int ${footer}`, builder: `# Contract: builder -You build ONE task of \`${join(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). +You build ONE task of \`${join(runAbs, "BUILD-PLAN.json")}\`, test-first, in your OWN isolated git worktree (references/orchestration.md Pattern 5 + references/build-playbook.md). Your prompt names your task (\`TASK=\`). If your TASK id is no longer in the worklist, skip it and say so in your summary. 1. Read your task in the plan. Its \`acceptance\` entries POINT into \`${join(runAbs, "SRD.json")}\` (\`functional[frId].acceptance[index]\`) — the SRD stays the single source of truth for what "done" means. 2. Work ONLY inside your own git worktree (the workflow dispatches you with \`isolation: 'worktree'\`). TDD each acceptance criterion: failing test first, then make it pass — and **every test names its FR id** (e.g. \`describe("FR-001 …")\`; that is what \`verify\` greps for). diff --git a/tests/__snapshots__/orchestrate.test.ts.snap b/tests/__snapshots__/orchestrate.test.ts.snap index 36e422a..c28e13d 100644 --- a/tests/__snapshots__/orchestrate.test.ts.snap +++ b/tests/__snapshots__/orchestrate.test.ts.snap @@ -79,7 +79,7 @@ exports[`orchestrate — contracts & runbook > golden shape (paths normalized) > You are an adversarial skeptic verifying that each SRD claim is actually SUPPORTED by the evidence it cites (references/orchestration.md Pattern 4). Assume the citation is decorative until the evidence proves otherwise. -Worklist: \`/VERIFY.todo.json\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). +Worklist: \`/VERIFY.todo.json\` (\`{ pairs: [...] }\`; each pair has \`claimId\`, \`kind\`, \`claim\`, \`evidenceId\`, \`source\`, \`digest\`). Handle ONLY the pairs whose \`claimId::evidenceId\` key is named in your prompt (\`PAIRS=\`). If a PAIRS key is no longer in the worklist, skip it and say so in your note. For EACH of your pairs: @@ -114,7 +114,7 @@ const RUN = "" const ENGINE = "" const WORKLIST = "/evidence/evidence.json" const AGENTS = RUN + '/orchestration/agents' -const BATCHES = [["feature (should): \\"Tag and organize saved articles\\" has no matchable evidence → drill: node web --out --q \\"Tag and organize saved articles\\"","feature (should): \\"Import an existing Pocket or Instapaper export\\" has no matchable evidence → drill: node web --out --q \\"Import an existing Pocket or Instapaper export\\"","feature (could): \\"Read articles offline on mobile\\" has no matchable evidence → drill: node web --out --q \\"Read articles offline on mobile\\"","tech: \\"PostgreSQL\\" has no docs/StackOverflow grounding → drill: node tech --out --q \\"PostgreSQL\\"","tech: \\"Next.js\\" has no docs/StackOverflow grounding → drill: node tech --out --q \\"Next.js\\"","oss seed: https://github.com/wallabag/wallabag yielded no mined evidence → drill: node oss --out --seeds https://github.com/wallabag/wallabag"]] +const BATCHES = [["feature (should): \\"Tag and organize saved articles\\" has no matchable evidence → drill: node web --out --q 'Tag and organize saved articles'","feature (should): \\"Import an existing Pocket or Instapaper export\\" has no matchable evidence → drill: node web --out --q 'Import an existing Pocket or Instapaper export'","feature (could): \\"Read articles offline on mobile\\" has no matchable evidence → drill: node web --out --q 'Read articles offline on mobile'","tech: \\"PostgreSQL\\" has no docs/StackOverflow grounding → drill: node tech --out --q 'PostgreSQL'","tech: \\"Next.js\\" has no docs/StackOverflow grounding → drill: node tech --out --q 'Next.js'","oss seed: https://github.com/wallabag/wallabag yielded no mined evidence → drill: node oss --out --seeds https://github.com/wallabag/wallabag"]] const SCHEMA = {"type":"object","required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","required":["gap","summary","urls"],"properties":{"gap":{"type":"string","description":"the gap label, verbatim from your prompt"},"summary":{"type":"string","description":"<=5 lines: what was found and why it matters to this product"},"urls":{"type":"array","items":{"type":"string"},"description":"URLs worth grounding, best first"}}}}}} function contract(name, extra) { diff --git a/tests/analyze.test.ts b/tests/analyze.test.ts index 59632ad..23bcbd9 100644 --- a/tests/analyze.test.ts +++ b/tests/analyze.test.ts @@ -75,7 +75,18 @@ describe("analyzeRun — the post-research gap signal", () => { it("reports a gap for a competitor the research never surfaced", () => { const r = analyzeRun(makeRun({ briefOverride: { competitors: [...brief.competitors, "Zzyzx"] } })); expect(r.unmatchedCompetitors).toEqual(["Zzyzx"]); - expect(r.suggestions.join("\n")).toMatch(/construct web --out .* --q "Zzyzx"/); + expect(r.suggestions.join("\n")).toMatch(/construct web --out .* --q 'Zzyzx'/); + }); + + it("single-quotes drill queries so shell metacharacters in brief free-text stay inert", () => { + const hostile = "pwn `whoami` $(id) and it's\nmultiline"; + const out = makeRun({ briefOverride: { competitors: [...brief.competitors, hostile] } }); + const r = analyzeRun(out); + const s = r.suggestions.find((x) => x.includes("pwn")); + expect(s).toBeDefined(); + // Single-quoted for the shell: backticks and $() cannot expand, the embedded + // apostrophe is escaped as '"'"' and the newline is flattened to a space. + expect(s).toBe(`construct web --out ${out} --q 'pwn \`whoami\` $(id) and it'"'"'s multiline'`); }); it("surfaces dossier notes (angle failures) from meta.json", () => { diff --git a/tests/orchestrate.test.ts b/tests/orchestrate.test.ts index 74a7418..4c1c474 100644 --- a/tests/orchestrate.test.ts +++ b/tests/orchestrate.test.ts @@ -332,6 +332,27 @@ describe("orchestrate — contracts & runbook", () => { expect(builder).toContain("BUILD-PLAN.json"); }); + it("sanitizes backticks out of the idea so the contract's inline-code span survives", () => { + const run = makeRun({ review: true }); + const raw = JSON.parse(readFileSync(join(run, "brief.json"), "utf8")); + raw.idea = "a `save-for-later` app\nwith `backticks`"; + writeFileSync(join(run, "brief.json"), JSON.stringify(raw)); + orchestrateRun(run, ENGINE); + const researcher = readFileSync(join(run, "orchestration", "agents", "researcher.md"), "utf8"); + const line = researcher.split("\n").find((l) => l.startsWith("Product one-liner:")); + // The interpolated idea stays ONE inline-code span: no interior backtick may + // close it early, and the newline flattens to a space. + expect(line).toBe("Product one-liner: `a 'save-for-later' app with 'backticks'`"); + }); + + it("worklist-driven contracts carry the family stale-id rule", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + const read = (role: string) => readFileSync(join(run, "orchestration", "agents", `${role}.md`), "utf8"); + expect(read("claim-reviewer")).toContain("If a PAIRS key is no longer in the worklist, skip it and say so in your note"); + expect(read("builder")).toContain("If your TASK id is no longer in the worklist, skip it and say so in your summary"); + }); + it("the runbook covers every phase with concrete paths and the phase status", () => { const run = makeRun({ review: true, frontier: true }); orchestrateRun(run, ENGINE);