From 8c57d5ed432987e67574982643b93f47b30c11f0 Mon Sep 17 00:00:00 2001 From: maxgfr <25312957+maxgfr@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:55:23 +0200 Subject: [PATCH] feat(orchestrate): emit the research/claim-review/adr-judges/build fan-outs + contracts + runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagate the family orchestrate command (reference implementation: ultra11y) onto construct's own worklists. `construct orchestrate --out [--phase

] [--adr ] [--eco] [--list]` emits, from the run's CURRENT file-backed state into /orchestration/: - research.workflow.mjs — Pattern 1: one researcher per `analyze` gap (reuses analyzeRun internally; each unit carries the gap + its drill command rewritten onto the absolute engine path); fold = ONE pinned research re-run + re-analyze. - claim-review.workflow.mjs — Pattern 4: one skeptic per VERIFY.todo.json claim↔evidence pair (keyed claimId::evidenceId); the returned { pairs } fragments are exactly what `review --apply` accepts; gate = `check --semantic`. - adr-judges.workflow.mjs — Pattern 3: the fixed 3-lens panel (feasibility / operations-cost / user-value) over ONE contested ADR (`--adr ` required; ADR + cited evidence pasted in as JSON constants — no run-folder access); fold = majority reduce + `render --from-srd`. - build.workflow.mjs — Pattern 5: one TDD builder per readyFrontier task, dispatched with isolation: 'worktree' (the one sanctioned write surface outside the orchestrator); fold = BUILD-PLAN merge + `verify`. - agents/.md dispatch contracts (all roles, idempotent, one-writer footer, also in --eco) and RUNBOOK.md (always — the sequential fallback). Family invariants: pure-JSON meta on line 1, absolute constants, real units batched (8/agent; 1 for build and the panel), deterministic emission (no Date.now/Math.random), exit 2 on missing run / unknown / not-ready phase naming the exact producing command, ≤3-unit eco nudge, empty-worklist skip. Pattern 2 (adversarial review), the interview and the brainstorm are deliberately never fanned out. Docs: SKILL.md gains the "Orchestration — route by harness" table, an orchestrate cheat-sheet bullet and fan-out pointers at the research / claim-support / judge-panel / build steps (frontmatter description untouched, 993 ≤ 1000 chars); references/orchestration.md gets a header note that the engine now emits its patterns; DOCUMENTATION.md documents the new module pair. 30 new tests (ported ultra11y battery, fixtures through the real engine writers) — 462 total green. Co-Authored-By: Claude Fable 5 --- DOCUMENTATION.md | 21 + scripts/construct.mjs | 576 ++++++++++++++++++- skills/construct/SKILL.md | 51 +- skills/construct/references/orchestration.md | 11 + skills/construct/scripts/construct.mjs | 576 ++++++++++++++++++- src/cli.ts | 69 ++- src/orchestrate-templates.ts | 343 +++++++++++ src/orchestrate.ts | 295 ++++++++++ tests/__snapshots__/orchestrate.test.ts.snap | 141 +++++ tests/orchestrate.test.ts | 436 ++++++++++++++ 10 files changed, 2483 insertions(+), 36 deletions(-) create mode 100644 src/orchestrate-templates.ts create mode 100644 src/orchestrate.ts create mode 100644 tests/__snapshots__/orchestrate.test.ts.snap create mode 100644 tests/orchestrate.test.ts diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a4d3f87..f8a722a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -126,6 +126,27 @@ tags after an id shift are flagged). `--run-tests` opts into executing `conventions.testCommand` + per-task `verify.commands` (via `util.ts::sh`); `--strict` fails a built must-have FR with no referencing test. +### `orchestrate` (`src/orchestrate.ts`, `src/orchestrate-templates.ts`) +Emits the run's multi-agent orchestration from its CURRENT file-backed state +into `/orchestration/`: one launchable `.workflow.mjs` per ready +fan-out phase, the dispatch contracts (`agents/.md`, all four roles, +idempotent) and a sequential `RUNBOOK.md` fallback (always). The four phases +mechanise the fan-out patterns of `references/orchestration.md`: `research` +(one researcher per `analyze` gap — reuses `analyzeRun` internally, since +analyze prints rather than persists), `claim-review` (one skeptic per +`VERIFY.todo.json` pair, keyed `claimId::evidenceId`; the returned `{ pairs }` +fragments are exactly what `review --apply` accepts), `adr-judges` (the fixed +3-lens panel over ONE contested ADR — `--adr ` required; the ADR + cited +evidence are pasted into the workflow as JSON constants) and `build` (one +builder per `readyFrontier` task, dispatched with `isolation: 'worktree'` — +the one sanctioned write surface outside the orchestrator). Emission is +deterministic (no clock, no randomness — workflows must run under the Workflow +tool) and per-phase by design: each worklist only exists after its engine step. +Exit 2 on a missing run, unknown phase, or a phase whose worklist doesn't exist +yet (the error names the producing command); `--list` prints readiness JSON; +`--eco` emits only RUNBOOK + contracts. Pattern 2 (adversarial review), the +interview and the brainstorm are never emitted — single-role by design. + ## The three axes - **Angles** (`market | oss | tech | semantic`) — *how* you research. diff --git a/scripts/construct.mjs b/scripts/construct.mjs index be50214..ce2c16f 100755 --- a/scripts/construct.mjs +++ b/scripts/construct.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // src/cli.ts -import { resolve as resolve3, join as join15 } from "path"; -import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs"; +import { resolve as resolve4, join as join17 } from "path"; +import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs"; import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url"; import { realpathSync } from "fs"; @@ -4226,6 +4226,486 @@ function formatVerifyReport(r, runDir) { return lines.join("\n"); } +// src/orchestrate.ts +import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs"; +import { join as join16, resolve as resolve3 } from "path"; + +// src/orchestrate-templates.ts +import { join as join15 } from "path"; +var ADR_LENSES = ["feasibility", "operations-cost", "user-value"]; +function oneWriterFooter(runAbs, sanctionedWrite) { + return ` +## Return, don't write (the one-writer rule) + +Return ONLY the structured output specified above. Subagents NEVER write into the run folder: do not write, edit, or delete any file there, and do not run any engine command that writes it (\`research\`, \`review\`, \`review --apply\`, \`render\`, \`init\`, \`brainstorm --merge\`). Drill commands never write the dossier \u2014 \`web|oss|tech|so\` print evidence to stdout and are safe. The orchestrator is the sole writer: it folds your returned fragments in serially and runs the gates itself. One writer, many readers \u2014 no races, no clobbered evidence.${sanctionedWrite ? ` + +${sanctionedWrite}` : ""} + +Exception for oversized prose: if a justification is too large to return, write ONLY to \`${join15(runAbs, "orchestration", "out")}/-.md\` (a file namespaced to you alone) and return its path. +`; +} +var RESEARCH_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" } + } + } + } + } +}; +var CLAIM_REVIEW_SCHEMA = { + type: "object", + required: ["pairs"], + properties: { + pairs: { + type: "array", + items: { + type: "object", + required: ["claimId", "evidenceId", "verdict", "note"], + properties: { + claimId: { type: "string", description: "verbatim from the worklist" }, + evidenceId: { type: "string", description: "verbatim from the worklist" }, + verdict: { enum: ["supported", "partial", "refuted", "unsupported"] }, + note: { type: "string", description: "<=200 chars, grounded in the digest/source you read" } + } + } + } + } +}; +var ADR_JUDGE_SCHEMA = { + type: "object", + required: ["lens", "score", "rationale"], + properties: { + lens: { enum: [...ADR_LENSES] }, + score: { type: "integer", minimum: 1, maximum: 5 }, + rationale: { type: "string", description: "one paragraph, nothing else" } + } +}; +var BUILDER_SCHEMA = { + type: "object", + required: ["taskId", "status", "summary", "artifacts", "tests"], + properties: { + taskId: { type: "string" }, + status: { enum: ["done", "blocked"] }, + summary: { type: "string", description: "what was built, TDD evidence (RED then GREEN)" }, + worktree: { type: "string", description: "absolute path of your git worktree holding the committed work" }, + artifacts: { type: "array", items: { type: "string" }, description: "app-relative paths implementing the task" }, + tests: { type: "array", items: { type: "string" }, description: "app-relative test files (each names its FR id)" }, + blockers: { type: "array", items: { type: "string" } } + } +}; +var PHASE_SPECS = { + research: { + role: "researcher", + title: "Research fan-out", + schema: RESEARCH_SCHEMA, + batchSize: 8, + description: (n) => `Research the ${n} evidence gap(s) construct analyze found (fan-out; the orchestrator folds URLs into ONE pinned research re-run)`, + extraExpr: "'GAPS (yours only, each with its drill command):\\n- ' + batch.join('\\n- ')", + applyHint: (engine, run) => [ + `node ${engine} research --out ${run} --angles market,oss,tech --url [--docs-url ]`, + `node ${engine} analyze --out ${run}` + ] + }, + "claim-review": { + role: "claim-reviewer", + title: "Claim review", + schema: CLAIM_REVIEW_SCHEMA, + batchSize: 8, + description: (n) => `Adversarially verify the ${n} claim\u2194evidence pair(s) of a construct SRD (skeptic fan-out; the orchestrator folds the verdicts and gates)`, + extraExpr: "'PAIRS=' + batch.join(',')", + applyHint: (engine, run) => [`node ${engine} review --apply verdicts.json --out ${run}`, `node ${engine} check --out ${run} --semantic`] + }, + "adr-judges": { + role: "adr-judge", + title: "Judge panel", + schema: ADR_JUDGE_SCHEMA, + batchSize: 1, + description: () => "Judge ONE contested ADR through the 3-lens panel (feasibility / operations & cost / user value); majority reduce", + extraExpr: "'LENS=' + batch[0] + '\\nADR = ' + JSON.stringify(ADR) + '\\nCITED EVIDENCE = ' + JSON.stringify(EVIDENCE)", + applyHint: (engine, run) => [`node ${engine} render --out ${run} --from-srd`] + }, + build: { + role: "builder", + title: "Build frontier", + schema: BUILDER_SCHEMA, + batchSize: 1, + description: (n) => `Build the ${n} ready BUILD-PLAN task(s) of this milestone frontier \u2014 one TDD builder per task, each in its own git worktree`, + extraExpr: "'TASK=' + batch.join(',')", + agentOpts: ", isolation: 'worktree'", + applyHint: (engine, run) => [`node ${engine} verify --out ${run}`] + } +}; +function phaseSpec(name) { + const spec = PHASE_SPECS[name]; + if (!spec) throw new Error(`no phase spec for "${name}"`); + return spec; +} +function toBatches(ids, batchSize) { + const out = []; + for (let i = 0; i < ids.length; i += batchSize) out.push(ids.slice(i, i + batchSize)); + return out; +} +var FOLD_PREAMBLE = { + research: [ + "// One-writer rule: this workflow only COLLECTS research fragments (summaries + URLs).", + "// The main agent folds them in serially with ONE pinned research re-run \u2014 a research run", + "// REBUILDS the dossier from exactly the angles/URLs it is given, so pass every angle \u2014", + "// then re-measures the gaps:" + ], + "claim-review": [ + "// One-writer rule: this workflow only COLLECTS verdict fragments. The main agent merges", + "// them into ONE verdicts.json (order-independent, keyed claimId::evidenceId \u2014 an omitted", + "// pair is reported unadjudicated, never silently passed), then folds and gates:" + ], + "adr-judges": [ + "// One-writer rule: this workflow only COLLECTS the 3 lens verdicts. The main agent", + "// majority-reduces them (pass = >=2 lenses scoring >=3): record one line per lens in the", + "// ADR's *Alternatives considered*, flip status proposed -> accepted in SRD.json only on a", + "// pass (on a fail, take the strongest rationale back to the user), then re-emit the tree:" + ], + build: [ + "// One-writer rule: builders write code ONLY in their own git worktrees. The main agent", + "// merges each worktree (serialising tasks that touch app-shared files \u2014 routing, schema,", + "// the test harness), folds artifacts/tests/status into BUILD-PLAN.json itself, then referees:" + ] +}; +function phaseWorkflowScript(ph, runAbs, engineAbs, units, adr) { + const spec = phaseSpec(ph.name); + const scriptPath = join15(runAbs, "orchestration", `${ph.name}.workflow.mjs`); + const meta = { name: `construct-${ph.name}`, description: spec.description(units.length), phases: [{ title: spec.title }] }; + const adrConsts = adr ? [`const ADR = ${JSON.stringify(adr.adr)}`, `const EVIDENCE = ${JSON.stringify(adr.evidence)}`] : []; + const tail = FOLD_PREAMBLE[ph.name] ?? []; + return [ + `export const meta = ${JSON.stringify(meta)}`, + ``, + `// NOT a plain Node script: launch via the Workflow tool \u2014 Workflow({ scriptPath: ${JSON.stringify(scriptPath)} }).`, + `// Emitted by \`construct orchestrate\` from the CURRENT run state. The run is the source`, + `// of truth: if its worklist changes, re-run \`orchestrate --phase ${ph.name}\` before launching.`, + ``, + `// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness).`, + `const RUN = ${JSON.stringify(runAbs)}`, + `const ENGINE = ${JSON.stringify(engineAbs)}`, + `const WORKLIST = ${JSON.stringify(ph.worklist)}`, + `const AGENTS = RUN + '/orchestration/agents'`, + `const BATCHES = ${JSON.stringify(toBatches(units, spec.batchSize))}`, + ...adrConsts, + `const SCHEMA = ${JSON.stringify(spec.schema)}`, + ``, + `function contract(name, extra) {`, + ` return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n'`, + ` + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n'`, + ` + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' \u2014 stdout drills and read-only commands only.'`, + ` + (extra ? '\\n' + extra : '')`, + `}`, + ``, + `log('construct ${ph.name}: ' + ${JSON.stringify(String(units.length))} + ' unit(s) across ' + BATCHES.length + ' agent(s)')`, + ``, + `phase(${JSON.stringify(spec.title)})`, + `const results = await pipeline(BATCHES, (batch, _item, i) =>`, + ` agent(contract('${spec.role}', ${spec.extraExpr}), { label: '${ph.name}:' + (i + 1), phase: ${JSON.stringify(spec.title)}, agentType: 'general-purpose', schema: SCHEMA${spec.agentOpts ?? ""} }))`, + ``, + ...tail, + ...spec.applyHint(engineAbs, runAbs).map((c) => `// ${c}`), + `return { phase: ${JSON.stringify(ph.name)}, worklist: WORKLIST, results: results.filter(Boolean) }`, + `` + ].join("\n"); +} +function agentContracts(runAbs, engineAbs, idea) { + const footer = oneWriterFooter(runAbs); + const builderFooter = oneWriterFooter( + 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)"; + return { + researcher: `# Contract: researcher + +You research evidence gaps of a construct run \u2014 the features, competitors, candidate tech and OSS seeds that \`analyze\` proved will render UNGROUNDED as-is (references/orchestration.md Pattern 1). + +Product one-liner: ${product} + +Your prompt lists your gaps (\`GAPS\`), each with its matching drill command. For EACH of your gaps: + +1. Run the drill (\`node ${engineAbs} web|oss|tech|so ... [--json]\`) and read the items. Drills print evidence to stdout and never write the dossier \u2014 they are safe to run in parallel. +2. Use your own WebSearch for what the drill misses (competitor pages, docs, issue threads, comparisons). +3. Judge relevance against the product one-liner and the gap \u2014 keep only what would actually ground this claim. + +Return (structured output): \`{ "findings": [{ "gap", "summary", "urls" }] }\` \u2014 your GAPS only. Per gap: a \u22645-line summary of what was found and why it matters to this product, and the URLs worth grounding, best first. The orchestrator folds ALL returned URLs into ONE pinned \`research\` re-run (a research run rebuilds the dossier from exactly the angles/URLs it is given), then re-runs \`analyze\`. +${footer}`, + "claim-reviewer": `# Contract: claim-reviewer + +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=\`). + +For EACH of your pairs: + +1. Read the pair's \`claim\` and its \`digest\` (the cited item's snippet). You may open the evidence source URL (see \`${join15(runAbs, "evidence", "EVIDENCE.md")}\`) for more context. A digest flagged \`[low-signal snippet \u2026]\` must be adjudicated skeptically \u2014 never grant \`supported\` on the URL alone. +2. Judge the claim\u2194evidence link: + - \`supported\` \u2014 the cited evidence directly backs the claim. + - \`partial\` \u2014 it backs a weaker version of the claim. + - \`unsupported\` \u2014 it is irrelevant / does not bear on the claim. + - \`refuted\` \u2014 it contradicts the claim. + When unsure, choose the HARSHER verdict \u2014 a false pass is worse than a false fail. +3. \`note\` is REQUIRED \u2014 \u2264200 chars grounded in what you actually read (quote or paraphrase the decisive text). + +Return (structured output): \`{ "pairs": [{ "claimId", "evidenceId", "verdict", "note" }] }\` \u2014 ids VERBATIM, your PAIRS only. The fold cross-checks the worklist: an invalid verdict token reads as unadjudicated (not as a failure) and an omitted pair is reported unadjudicated \u2014 never silently passed. +${footer}`, + "adr-judge": `# Contract: adr-judge + +You are ONE lens of a 3-judge panel over ONE contested ADR (references/orchestration.md Pattern 3). Your prompt carries your \`LENS\`, the \`ADR\` (title, context, decision, consequences, alternatives) and the \`CITED EVIDENCE\` snippets \u2014 pasted in; you do not need the run folder. + +The lenses: + +- \`feasibility\` \u2014 can this team build it in this timeline on this stack? +- \`operations-cost\` \u2014 what does it cost to run, observe, upgrade, exit? +- \`user-value\` \u2014 does this decision serve the stated users and value prop? + +Judge ONLY through your lens; the other two are someone else's job. If the ADR cites no evidence, judge from its text alone and say so in the rationale \u2014 that grounding gap is itself signal. + +Return (structured output): \`{ "lens", "score", "rationale" }\` \u2014 a 1\u20135 integer score and a one-paragraph rationale, nothing else. The orchestrator decides by majority (\u22652 judges scoring \u22653), records one line per lens in the ADR's *Alternatives considered*, and flips \`status: proposed \u2192 accepted\` only on a pass. +${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=\`). + +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). +3. Run the app's test command yourself in the worktree. Do NOT run \`verify\` or the milestone gate \u2014 the orchestrator referees after folding the whole frontier. +4. NEVER edit \`BUILD-PLAN.json\`, \`SRD.json\` or anything in the run folder, and never touch files another frontier task owns \u2014 app-shared files (routing, schema, the test harness) are serialised by the orchestrator. + +Return (structured output): \`{ "taskId", "status", "summary", "worktree", "artifacts", "tests", "blockers" }\` \u2014 \`status\` is \`done\` or \`blocked\`, \`worktree\` is the absolute path holding your committed work, \`artifacts\`/\`tests\` are app-relative. The orchestrator merges your worktree, folds artifacts/tests/status into BUILD-PLAN.json itself, and runs \`node ${engineAbs} verify --out ${runAbs}\`. +${builderFooter}` + }; +} +function runbookMd(phases, runAbs, engineAbs) { + const status = phases.map((p) => `| ${p.name} | \`${p.worklist}\` | ${p.ready ? `ready (${p.items} unit(s))` : "not ready"} | \`${p.prerequisite}\` |`).join("\n"); + const engine = `node ${engineAbs}`; + const agents = join15(runAbs, "orchestration", "agents"); + return `# construct \u2014 sequential RUNBOOK (eco / no-subagent fallback) + +Run: \`${runAbs}\` \xB7 Engine: \`${engine}\` + +Generated by \`construct orchestrate\` from the CURRENT run state. This sequential path is +correctness-identical to the multi-agent workflows \u2014 same worklists, same contracts, same +gates; only wall-clock differs. Fan-out is an optimization, not a requirement (the +three-tier model of references/orchestration.md). + +## Phase status + +| Phase | Worklist | Status | Produce it with | +|---|---|---|---| +${status} + +## The loop (play every role yourself, one unit at a time) + +1. **Interview \u2192 brief** (if not done): \`${engine} init --idea "" --out ${runAbs}\`, then fill \`${join15(runAbs, "brief.json")}\` one question at a time (references/interview-playbook.md). +2. **Research, then dig every gap** \u2014 \`${engine} research --out ${runAbs}\` builds the dossier; \`${engine} analyze --out ${runAbs}\` names each gap + its drill command. For EVERY gap, apply \`${join15(agents, "researcher.md")}\` yourself (run the drill, WebSearch what it misses, keep the URLs worth grounding). Fold in serially with ONE pinned re-run: \`${engine} research --out ${runAbs} --angles market,oss,tech --url \` \u2192 re-run \`analyze\`. Loop until clean or the user stops you. +3. **Render**: \`${engine} render --out ${runAbs} --level complex\`, then enrich the SRD (SKILL.md step 4). +4. **Claim-support review** \u2014 \`${engine} review --out ${runAbs}\` writes \`${join15(runAbs, "VERIFY.todo.json")}\`. For EVERY pair, apply \`${join15(agents, "claim-reviewer.md")}\` yourself (verdict + note into a \`verdicts.json\`). Then fold: \`${engine} review --apply verdicts.json --out ${runAbs}\` and gate: \`${engine} check --out ${runAbs} --semantic\` (must exit 0 before presenting). +5. **Judge panel \u2014 only for ONE genuinely contested ADR** \u2014 apply \`${join15(agents, "adr-judge.md")}\` yourself three times (feasibility / operations-cost / user-value) over the pasted ADR + its cited evidence. Majority (\u22652 lenses \u22653) \u2192 one line per lens under *Alternatives considered*, flip \`proposed \u2192 accepted\` in \`${join15(runAbs, "SRD.json")}\`, re-emit: \`${engine} render --out ${runAbs} --from-srd\`. +6. **Build the frontier** \u2014 per ready task (\`${engine} status --out ${runAbs} --json\` \u2192 \`frontier\`), apply \`${join15(agents, "builder.md")}\` yourself (sequentially you may work in the app dir directly \u2014 no worktree needed); fold artifacts/tests/status into \`${join15(runAbs, "BUILD-PLAN.json")}\`, then \`${engine} verify --out ${runAbs}\`. Milestone gate once the frontier is folded: \`${engine} verify --out ${runAbs} --run-tests --strict\`. + +The adversarial SRD review (Pattern 2) stays a single fresh-eyes pass by design \u2014 run it +per references/adversarial-review.md; it is deliberately not a fan-out and not emitted here. + +With subagents available, prefer the emitted workflows instead: \`orchestrate --out ${runAbs} --phase

\` then \`Workflow({ scriptPath: "${join15(runAbs, "orchestration", "

.workflow.mjs")}" })\` \u2014 you stay the sole writer either way. +`; +} + +// src/orchestrate.ts +var PHASES = ["research", "claim-review", "adr-judges", "build"]; +var SMALL_WORKLIST = 3; +function loadSrd(runDir) { + const manifest = srdManifestPath(runDir); + if (!existsSync11(manifest)) return null; + try { + const srd = JSON.parse(readFileSync10(manifest, "utf8")); + return srd && typeof srd === "object" ? srd : null; + } catch { + return null; + } +} +function loadDossier(runDir) { + const path = join16(runDir, "evidence", "evidence.json"); + if (!existsSync11(path)) return []; + try { + const data = JSON.parse(readFileSync10(path, "utf8")); + return Array.isArray(data) ? data.filter( + (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" + ) : []; + } catch { + return []; + } +} +function researchUnits(runDir, engineAbs) { + if (!existsSync11(join16(runDir, "brief.json")) || !existsSync11(join16(runDir, "evidence", "evidence.json"))) return null; + try { + const r = analyzeRun(runDir); + const labels = [ + ...r.ungroundedFeatures.map((f) => `feature (${f.priority}): "${f.title}" has no matchable evidence`), + ...r.unmatchedCompetitors.map((c) => `competitor: "${c}" never surfaced in market evidence`), + ...r.unmatchedTech.map((t) => `tech: "${t}" has no docs/StackOverflow grounding`), + ...r.unminedSeeds.map((s) => `oss seed: ${s} yielded no mined evidence`) + ]; + return labels.map((label, i) => { + const drill = r.suggestions[i]?.replace(/^construct /, `node ${engineAbs} `); + return drill ? `${label} \u2192 drill: ${drill}` : label; + }); + } catch { + return null; + } +} +function listPhases(runDir, engineAbs) { + const run = resolve3(runDir); + const gaps = researchUnits(run, engineAbs); + const todoPath = join16(run, "VERIFY.todo.json"); + let pairKeys = null; + if (existsSync11(todoPath)) { + try { + const todo = JSON.parse(readFileSync10(todoPath, "utf8")); + if (todo && Array.isArray(todo.pairs)) { + pairKeys = todo.pairs.filter((p) => !!p && typeof p.claimId === "string" && typeof p.evidenceId === "string").map((p) => `${p.claimId}::${p.evidenceId}`); + } + } catch { + } + } + const srd = loadSrd(run); + const adrIds = srd && Array.isArray(srd.architecture?.adrs) ? srd.architecture.adrs.map((a) => a.id) : []; + const plan = loadPlan(run); + const frontier = plan ? readyFrontier(plan).frontier : null; + const renderCmd = `node ${engineAbs} render --out ${run} --level complex`; + return [ + { + name: "research", + ready: gaps !== null, + worklist: join16(run, "evidence", "evidence.json"), + items: gaps?.length ?? 0, + ids: gaps ?? [], + prerequisite: `node ${engineAbs} research --out ${run}` + }, + { + name: "claim-review", + ready: pairKeys !== null, + worklist: todoPath, + items: pairKeys?.length ?? 0, + ids: pairKeys ?? [], + prerequisite: `node ${engineAbs} review --out ${run}` + }, + { + name: "adr-judges", + ready: adrIds.length > 0, + worklist: srdManifestPath(run), + items: adrIds.length, + ids: adrIds, + prerequisite: renderCmd + }, + { + name: "build", + ready: frontier !== null, + worklist: join16(run, "BUILD-PLAN.json"), + items: frontier?.length ?? 0, + ids: frontier ?? [], + prerequisite: renderCmd + } + ]; +} +function adrPanelPayload(runDir, adrId) { + const srd = loadSrd(runDir); + const adr = srd?.architecture?.adrs?.find((a) => a.id === adrId); + if (!adr) return null; + const byId = new Map(loadDossier(runDir).map((e) => [e.id, e])); + const evidence = [...new Set(adr.evidence)].map((id) => byId.get(id)).filter((e) => !!e).map((e) => ({ id: e.id, source: e.source, ref: e.ref, digest: (e.snippet || e.title || e.ref).slice(0, 600) })); + return { adr, evidence }; +} +var err = (exitCode, errors, phases) => ({ exitCode, written: [], notices: [], errors, phases }); +function orchestrateRun(runDir, engineAbs, opts = {}) { + const run = resolve3(runDir); + if (!existsSync11(run)) { + return err(2, [`run dir not found: ${run}`], []); + } + const phases = listPhases(run, engineAbs); + const adrPhase = phases.find((p) => p.name === "adr-judges"); + const notices = []; + let selected = phases.filter((p) => p.ready && p.name !== "adr-judges"); + let adrPayload; + if (opts.phase !== void 0) { + const ph = phases.find((p) => p.name === opts.phase); + if (!ph) { + return err(2, [`unknown phase "${opts.phase}" \u2014 expected one of: ${PHASES.join(", ")}.`], phases); + } + if (!ph.ready) { + return err(2, [`phase "${ph.name}" is not ready \u2014 its worklist ${ph.worklist} is missing or unreadable. Produce it first: ${ph.prerequisite}`], phases); + } + if (ph.name === "adr-judges") { + const available = `this run's ADRs: ${ph.ids.join(", ")}`; + if (!opts.adr) { + return err( + 2, + [ + `phase "adr-judges" panels ONE contested ADR \u2014 pass --adr (${available}). Reserve it for a genuinely contested, hard-to-reverse decision (references/orchestration.md Pattern 3).` + ], + phases + ); + } + if (!ph.ids.includes(opts.adr)) { + return err(2, [`ADR "${opts.adr}" not found \u2014 ${available}.`], phases); + } + adrPayload = adrPanelPayload(run, opts.adr) ?? void 0; + if (!adrPayload) return err(2, [`ADR "${opts.adr}" could not be loaded from ${ph.worklist}.`], phases); + } + selected = [ph]; + } else if (adrPhase.ready) { + notices.push( + `phase "adr-judges": not emitted by default (a 3-lens panel over ONE contested ADR) \u2014 emit it explicitly: orchestrate --out ${run} --phase adr-judges --adr (this run's ADRs: ${adrPhase.ids.join(", ")}).` + ); + } + const orchDir = join16(run, "orchestration"); + const agentsDir = join16(orchDir, "agents"); + mkdirSync6(join16(orchDir, "out"), { recursive: true }); + mkdirSync6(agentsDir, { recursive: true }); + const written = []; + let idea = ""; + try { + idea = loadBrief(run).idea; + } catch { + } + for (const [name, content] of Object.entries(agentContracts(run, engineAbs, idea))) { + const p = join16(agentsDir, `${name}.md`); + writeFileSync7(p, content); + written.push(p); + } + if (!opts.eco) { + for (const ph of selected) { + const units = ph.name === "adr-judges" ? [...ADR_LENSES] : ph.ids; + if (units.length === 0) { + notices.push(`phase "${ph.name}": worklist is empty \u2014 nothing to orchestrate.`); + continue; + } + if (ph.name !== "adr-judges" && units.length <= SMALL_WORKLIST) { + notices.push(`phase "${ph.name}": only ${units.length} unit(s) \u2014 the sequential --eco path is equivalent and cheaper.`); + } + const p = join16(orchDir, `${ph.name}.workflow.mjs`); + writeFileSync7(p, phaseWorkflowScript(ph, run, engineAbs, units, adrPayload)); + written.push(p); + } + } + const rb = join16(orchDir, "RUNBOOK.md"); + writeFileSync7(rb, runbookMd(phases, run, engineAbs)); + written.push(rb); + return { exitCode: 0, written, notices, errors: [], phases }; +} + // src/cli.ts var HELP = `construct v${VERSION} Turn a product idea into a grounded, buildable SRD suite. Interview \u2192 research @@ -4244,6 +4724,7 @@ Usage: construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app

] [--run-tests] [--strict] [--json] construct status --out [--json] + construct orchestrate --out [--phase research|claim-review|adr-judges|build] [--adr ] [--eco] [--list] construct semantic up|down|status Commands: @@ -4272,6 +4753,15 @@ Commands: verify Check a built app against BUILD-PLAN.json + the SRD (static by default; --run-tests executes the declared test commands). status Show what exists in a run (brief / evidence / SRD / check). + orchestrate Emit the run's multi-agent orchestration from its CURRENT state + into /orchestration/: one launchable workflow script per + ready fan-out phase (research gaps \xB7 claim-review pairs \xB7 the + adr-judges 3-lens panel \xB7 build frontier tasks), the dispatch + contracts (agents/.md) and a sequential RUNBOOK.md fallback. + Subagents RETURN fragments; you stay the sole writer of the run + folder (references/orchestration.md). Exits 2 when the named + phase's worklist does not exist yet \u2014 and says which command + produces it. Re-run after any worklist change (idempotent). semantic Manage the optional local Docker stack (Qdrant + Ollama + SearXNG). Options: @@ -4294,6 +4784,13 @@ Options: pairs (default: review ALL cited pairs; dropped pairs are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) + --phase For 'orchestrate': emit one phase only \u2014 + research | claim-review | adr-judges | build + --adr For 'orchestrate': the contested ADR the judge panel + rules on (required with --phase adr-judges) + --eco For 'orchestrate': emit only RUNBOOK.md + agents/*.md \u2014 + the explicit low-token sequential path + --list For 'orchestrate': print the phases + readiness as JSON --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS --web-engine auto | searxng | ddg | claude (default: auto) @@ -4327,6 +4824,7 @@ var COMMANDS = /* @__PURE__ */ new Set([ "verify", "review", "status", + "orchestrate", "semantic" ]); var VALUE_FLAGS = /* @__PURE__ */ new Set([ @@ -4346,9 +4844,11 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "min-grounding", "app", "apply", - "max-review" + "max-review", + "phase", + "adr" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd", "eco", "list"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -4436,7 +4936,7 @@ function csv(s) { function requireOut(p) { const out = (p.values.out || p.values.run || "").trim(); if (!out) fail("missing --out "); - return resolve3(out); + return resolve4(out); } var warnBrief = (w) => void process.stderr.write(` \u26A0 brief: ${w} `); @@ -4483,7 +4983,7 @@ async function main() { case "init": { const idea = p.values.idea; if (!idea) fail('missing --idea ""'); - const out = p.values.out ? resolve3(p.values.out) : resolve3(slugify(idea) || "construct-run"); + const out = p.values.out ? resolve4(p.values.out) : resolve4(slugify(idea) || "construct-run"); const brief = initBrief(idea, (/* @__PURE__ */ new Date()).toISOString()); const path = saveBrief(out, brief); process.stderr.write( @@ -4536,7 +5036,7 @@ async function main() { const c = brainstormCounts(b); process.stderr.write( [ - `construct: brainstorm board at ${join15(out, "BRAINSTORM.md")}`, + `construct: brainstorm board at ${join17(out, "BRAINSTORM.md")}`, ` ideas: ${b.ideas.length} (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`, ` next: generate ideas WITH the user (references/brainstorm-playbook.md), mark statuses in`, ` brainstorm.json, then: construct brainstorm --out ${out} --merge` @@ -4605,7 +5105,7 @@ async function main() { const r2 = renderFromSRD(out, { merge: p.bools.has("merge"), prd: p.bools.has("prd") }); process.stderr.write( [ - `construct: re-emitted the SRD tree from ${join15(out, "SRD.json")}`, + `construct: re-emitted the SRD tree from ${join17(out, "SRD.json")}`, ` files: ${r2.files.length} (${r2.srd.functional.length} FR \xB7 ${r2.srd.nonFunctional.length} NFR \xB7 ${r2.srd.architecture.adrs.length} ADR)`, ` next: construct check --out ${out}` ].join("\n") + "\n" @@ -4632,7 +5132,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); `construct: rendered the ${level} SRD for "${brief.idea}"`, ` files: ${r.files.length} (${r.srd.functional.length} FR \xB7 ${r.srd.nonFunctional.length} NFR \xB7 ${r.srd.architecture.adrs.length} ADR)`, ...design ? [` design: ${design.components.length} components \xB7 ${design.tokens.length} tokens \xB7 a11y ${design.accessibility.standard}`] : [], - ` manifest: ${join15(out, "SRD.json")}`, + ` manifest: ${join17(out, "SRD.json")}`, ` next: construct check --out ${out}` ].join("\n") + "\n" ); @@ -4670,7 +5170,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); case "review": { const out = requireOut(p); if (p.values.apply) { - const res = applyVerdicts(out, resolve3(p.values.apply)); + const res = applyVerdicts(out, resolve4(p.values.apply)); if (p.bools.has("json")) process.stdout.write(JSON.stringify(res, null, 2) + "\n"); else process.stdout.write(formatReviewReport(res) + "\n"); if (!res.ok) process.exit(1); @@ -4696,7 +5196,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); case "verify": { const out = requireOut(p); const res = verifyRun(out, { - appDir: p.values.app ? resolve3(p.values.app) : void 0, + appDir: p.values.app ? resolve4(p.values.app) : void 0, runTests: p.bools.has("run-tests"), strict: p.bools.has("strict") }); @@ -4715,7 +5215,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); process.stdout.write(JSON.stringify(plan ? readyFrontier(plan) : null, null, 2) + "\n"); return; } - const has = (rel) => existsSync11(join15(out, rel)) ? "\u2713" : "\xB7"; + const has = (rel) => existsSync12(join17(out, rel)) ? "\u2713" : "\xB7"; const planLine = plan ? ` \u2713 BUILD-PLAN.json (build: ${plan.tasks.filter((t) => t.status === "done").length}/${plan.tasks.length} tasks done)` : ` \xB7 BUILD-PLAN.json (build plan)`; const bs = loadBrainstorm(out); const bsLine = bs ? (() => { @@ -4735,6 +5235,52 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); ); return; } + case "orchestrate": { + const rawOut = (p.values.out || p.values.run || "").trim(); + if (!rawOut) { + process.stderr.write("construct orchestrate: --out is required (the run folder to orchestrate).\n"); + process.exit(2); + } + const runDir = resolve4(rawOut); + const engineAbs = realpathSync(fileURLToPath2(import.meta.url)); + if (p.bools.has("list")) { + if (!existsSync12(runDir)) { + process.stderr.write(`construct orchestrate: run dir not found: ${runDir}. +`); + process.exit(2); + } + process.stdout.write(JSON.stringify({ phases: listPhases(runDir, engineAbs) }, null, 2) + "\n"); + return; + } + const res = orchestrateRun(runDir, engineAbs, { + phase: p.values.phase, + adr: p.values.adr, + eco: p.bools.has("eco") + }); + if (res.exitCode !== 0) { + for (const e of res.errors) process.stderr.write(`construct orchestrate: ${e} +`); + process.exit(res.exitCode); + } + process.stderr.write("construct orchestrate: generated\n"); + for (const w of res.written) process.stderr.write(` ${w} +`); + for (const n of res.notices) process.stderr.write(`construct orchestrate: note \u2014 ${n} +`); + const workflows = res.written.filter((w) => w.endsWith(".workflow.mjs")); + if (workflows.length) { + process.stderr.write("\n"); + for (const w of workflows) process.stderr.write(`Launch: Workflow({ scriptPath: ${JSON.stringify(w)} }) +`); + process.stderr.write( + "Then fold the returned fragments in yourself and run the fold command named at the tail of each workflow (you stay the sole writer).\n" + ); + } else { + process.stderr.write(`Follow ${join17(runDir, "orchestration", "RUNBOOK.md")} sequentially (the eco path). +`); + } + return; + } case "semantic": { const action = p.positional[0] ?? "status"; const r = semanticControl(action); @@ -4745,10 +5291,10 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); } } function loadEvidence4(runDir) { - const path = join15(runDir, "evidence", "evidence.json"); - if (!existsSync11(path)) return []; + const path = join17(runDir, "evidence", "evidence.json"); + if (!existsSync12(path)) return []; try { - const data = JSON.parse(readFileSync10(path, "utf8")); + const data = JSON.parse(readFileSync11(path, "utf8")); return Array.isArray(data) ? data.filter(isEvidenceItem) : []; } catch { return []; diff --git a/skills/construct/SKILL.md b/skills/construct/SKILL.md index cfd33fa..5793d89 100644 --- a/skills/construct/SKILL.md +++ b/skills/construct/SKILL.md @@ -65,6 +65,12 @@ No `npm install`, no API keys. Run `--help` for the full surface. Key commands: commands; `--strict` fails a built must-have with no referencing test. - `status --out [--json]` — what exists in the run so far; `--json` adds the build frontier (which BUILD-PLAN tasks are buildable now vs. blocked). +- `orchestrate --out [--phase research|claim-review|adr-judges|build] + [--adr ] [--eco] [--list]` — emit the run's multi-agent orchestration + from its CURRENT state into `/orchestration/`: one launchable workflow + script per ready fan-out phase, the dispatch contracts (`agents/.md`) + and a sequential `RUNBOOK.md` fallback. See *Orchestration — route by + harness* below. - `semantic up|down|status` — optional local Docker stack (Qdrant + Ollama + SearXNG). @@ -109,7 +115,9 @@ loop to completion; only pause to ask the user a real decision. It names exactly what is thin — features, competitors, candidate tech and OSS seeds with no matchable evidence — and prints the drill command that fixes each gap. **Fan out:** if you can spawn parallel subagents, dispatch one per - gap; each gets the brief one-liner, the gap, its drill command and its own + gap (the engine emits this ready to launch: + `node scripts/construct.mjs orchestrate --out --phase research`); + each gets the brief one-liner, the gap, its drill command and its own WebSearch, and returns a ≤5-line summary plus URLs worth grounding. Subagents MUST NOT write into the run folder — drills print to stdout; only `construct research` writes the dossier, and only YOU run it. Fold findings @@ -153,7 +161,8 @@ loop to completion; only pause to ask the user a real decision. `[blocker]`, use judgement on `[advisory]`, then re-run `check`. Loop while new blockers appear (cap: 3 rounds, then surface what remains to the user). For a genuinely contested, hard-to-reverse ADR at `complex` level, also run - the 3-judge panel from `references/orchestration.md`. + the 3-judge panel from `references/orchestration.md` (emit it: + `orchestrate --out --phase adr-judges --adr `). 6. **Validate (three layers).** - *Structural (hard):* `node scripts/construct.mjs check --out `. It @@ -168,7 +177,8 @@ loop to completion; only pause to ask the user a real decision. - *Claim-support (advisory → opt-in gate):* coverage counts citations; it does not check they hold. `construct review --out ` builds a claim↔evidence worklist; adjudicate each pair (fan out per `references/orchestration.md` - Pattern 4), assemble `verdicts.json`, `review --apply verdicts.json`, then + Pattern 4 — emitted by `orchestrate --out --phase claim-review`), + assemble `verdicts.json`, `review --apply verdicts.json`, then `check --out --semantic` to gate refuted/unsupported claims. Worth one pass over the load-bearing FRs/ADRs before presenting. Loop steps 3–6 until `check` passes structurally, the reviewer finds no new @@ -194,7 +204,8 @@ loop to completion; only pause to ask the user a real decision. - Same-milestone tasks carry no edges to each other, so their ready frontier (`status --out --json`) can be built in parallel — one isolated git worktree per task; you alone fold results into `BUILD-PLAN.json` (Pattern 5 - in `references/orchestration.md`). + in `references/orchestration.md`; fan out: + `orchestrate --out --phase build`). - Per milestone: `verify --out --run-tests --strict`, then a milestone adversarial review — fresh eyes hunting for an acceptance criterion no test actually exercises (see the playbook; @@ -202,6 +213,36 @@ loop to completion; only pause to ask the user a real decision. - If an FR proves wrong while building, amend the brief, re-render (progress merges by feature title), retag shifted FR ids, re-`check`. +## Orchestration — route by harness + +Four phases fan out over per-unit, file-backed state: **research** (one researcher per +`analyze` gap), **claim-review** (one skeptic per `VERIFY.todo.json` claim↔evidence pair), +**adr-judges** (the fixed 3-lens panel over ONE contested ADR) and **build** (one +worktree-isolated builder per ready BUILD-PLAN task). The engine manages the fan-out — +`orchestrate` emits the orchestration from the CURRENT run state, with absolute paths and +the real worklist units baked in: + +``` +node scripts/construct.mjs orchestrate --out [--phase research|claim-review|adr-judges|build] [--adr ] [--eco] [--list] +``` + +| Your harness | How to run each fan-out phase | +|---|---| +| Has the Workflow tool | `orchestrate --out --phase

`, then `Workflow({ scriptPath: "/orchestration/

.workflow.mjs" })`. Subagents RETURN fragments; fold them in yourself — the pinned `research` re-run, `review --apply`, the ADR majority reduce, the BUILD-PLAN fold — then gate as usual. | +| Subagents but no Workflow tool | Same `orchestrate`; dispatch one subagent per batch following `/orchestration/agents/.md` (the workflow script shows batches + prompts). One writer: you fold results in. | +| Eco mode, or no subagents | `orchestrate --out --eco` → follow `/orchestration/RUNBOOK.md` sequentially, playing each role yourself. Correctness-identical; only wall-clock differs. | + +Fan-out is an optimization, never a requirement — the gates (`check`, `review --apply`, +`verify`) are harness-independent and every phase has a sequential fallback with identical +artifacts. Subagents never write the run folder: the emitted contracts end with the +one-writer rule, and the fold always stays with you, the orchestrator (builders write code +only in their own isolated git worktrees — never the run folder). The judge panel is +opt-in — `--phase adr-judges --adr ` panels ONE genuinely contested ADR — and the +adversarial SRD review (Pattern 2) deliberately stays a single fresh-eyes reviewer, never +a fan-out. Re-run `orchestrate` whenever a worklist changes (emission is deterministic and +idempotent); `--phase

` before its worklist exists fails and names the command that +produces it. The underlying patterns live in `references/orchestration.md`. + ## What it produces (the SRD tree, under `--out`) ``` @@ -248,7 +289,7 @@ See `references/semantic-setup.md`. - `references/brainstorm-playbook.md` — the optional divergent step: generating candidate ideas across six angles and merging the kept ones into the brief. - `references/interview-playbook.md` — how to elicit the brief, one question at a time. - `references/research-playbook.md` — picking angles and digging deeper to "good enough". -- `references/orchestration.md` — the three-tier dynamic-workflow model and the subagent patterns: research fan-out, red team, judge panel, claim-support review fan-out, build fan-out (and the one-writer rule). +- `references/orchestration.md` — the three-tier dynamic-workflow model and the subagent patterns: research fan-out, red team, judge panel, claim-support review fan-out, build fan-out (and the one-writer rule). The fan-out patterns are emitted ready-to-launch by `construct orchestrate`. - `references/adversarial-review.md` — the red-team checklist and its findings contract. - `references/srd-authoring.md` — resolving 🧠 callouts, writing testable requirements and ADRs. - `references/design-system-authoring.md` — enriching the `complex` design system: tokens, components, screens/flows and the accessibility contract. diff --git a/skills/construct/references/orchestration.md b/skills/construct/references/orchestration.md index 9cb9422..0860c67 100644 --- a/skills/construct/references/orchestration.md +++ b/skills/construct/references/orchestration.md @@ -1,5 +1,16 @@ # Orchestration — dynamic workflows for wider research and harder review +> **The engine now EMITS these patterns.** `construct orchestrate --out +> [--phase research|claim-review|adr-judges|build] [--adr ] [--eco] [--list]` +> generates, from the run's CURRENT state, one launchable workflow script per +> fan-out pattern below (Pattern 1 → `research`, Pattern 4 → `claim-review`, +> Pattern 3 → `adr-judges`, Pattern 5 → `build`), the dispatch contracts +> (`/orchestration/agents/.md`, each ending with the one-writer rule) +> and a sequential `RUNBOOK.md` for tier 3 — absolute paths and the real +> worklist units baked in. Pattern 2 (the adversarial review) is deliberately +> not emitted: it is ONE fresh-eyes reviewer, not a fan-out. The prose below +> stays the source of truth at every tier. + construct's engine is single-process and deterministic; the *intelligence scaling* happens in how you, the orchestrating agent, drive it. Each pattern below is a **dynamic workflow** — a shape (parallel fan-out, bounded loop, or diff --git a/skills/construct/scripts/construct.mjs b/skills/construct/scripts/construct.mjs index be50214..ce2c16f 100755 --- a/skills/construct/scripts/construct.mjs +++ b/skills/construct/scripts/construct.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node // src/cli.ts -import { resolve as resolve3, join as join15 } from "path"; -import { existsSync as existsSync11, readFileSync as readFileSync10 } from "fs"; +import { resolve as resolve4, join as join17 } from "path"; +import { existsSync as existsSync12, readFileSync as readFileSync11 } from "fs"; import { pathToFileURL, fileURLToPath as fileURLToPath2 } from "url"; import { realpathSync } from "fs"; @@ -4226,6 +4226,486 @@ function formatVerifyReport(r, runDir) { return lines.join("\n"); } +// src/orchestrate.ts +import { existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs"; +import { join as join16, resolve as resolve3 } from "path"; + +// src/orchestrate-templates.ts +import { join as join15 } from "path"; +var ADR_LENSES = ["feasibility", "operations-cost", "user-value"]; +function oneWriterFooter(runAbs, sanctionedWrite) { + return ` +## Return, don't write (the one-writer rule) + +Return ONLY the structured output specified above. Subagents NEVER write into the run folder: do not write, edit, or delete any file there, and do not run any engine command that writes it (\`research\`, \`review\`, \`review --apply\`, \`render\`, \`init\`, \`brainstorm --merge\`). Drill commands never write the dossier \u2014 \`web|oss|tech|so\` print evidence to stdout and are safe. The orchestrator is the sole writer: it folds your returned fragments in serially and runs the gates itself. One writer, many readers \u2014 no races, no clobbered evidence.${sanctionedWrite ? ` + +${sanctionedWrite}` : ""} + +Exception for oversized prose: if a justification is too large to return, write ONLY to \`${join15(runAbs, "orchestration", "out")}/-.md\` (a file namespaced to you alone) and return its path. +`; +} +var RESEARCH_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" } + } + } + } + } +}; +var CLAIM_REVIEW_SCHEMA = { + type: "object", + required: ["pairs"], + properties: { + pairs: { + type: "array", + items: { + type: "object", + required: ["claimId", "evidenceId", "verdict", "note"], + properties: { + claimId: { type: "string", description: "verbatim from the worklist" }, + evidenceId: { type: "string", description: "verbatim from the worklist" }, + verdict: { enum: ["supported", "partial", "refuted", "unsupported"] }, + note: { type: "string", description: "<=200 chars, grounded in the digest/source you read" } + } + } + } + } +}; +var ADR_JUDGE_SCHEMA = { + type: "object", + required: ["lens", "score", "rationale"], + properties: { + lens: { enum: [...ADR_LENSES] }, + score: { type: "integer", minimum: 1, maximum: 5 }, + rationale: { type: "string", description: "one paragraph, nothing else" } + } +}; +var BUILDER_SCHEMA = { + type: "object", + required: ["taskId", "status", "summary", "artifacts", "tests"], + properties: { + taskId: { type: "string" }, + status: { enum: ["done", "blocked"] }, + summary: { type: "string", description: "what was built, TDD evidence (RED then GREEN)" }, + worktree: { type: "string", description: "absolute path of your git worktree holding the committed work" }, + artifacts: { type: "array", items: { type: "string" }, description: "app-relative paths implementing the task" }, + tests: { type: "array", items: { type: "string" }, description: "app-relative test files (each names its FR id)" }, + blockers: { type: "array", items: { type: "string" } } + } +}; +var PHASE_SPECS = { + research: { + role: "researcher", + title: "Research fan-out", + schema: RESEARCH_SCHEMA, + batchSize: 8, + description: (n) => `Research the ${n} evidence gap(s) construct analyze found (fan-out; the orchestrator folds URLs into ONE pinned research re-run)`, + extraExpr: "'GAPS (yours only, each with its drill command):\\n- ' + batch.join('\\n- ')", + applyHint: (engine, run) => [ + `node ${engine} research --out ${run} --angles market,oss,tech --url [--docs-url ]`, + `node ${engine} analyze --out ${run}` + ] + }, + "claim-review": { + role: "claim-reviewer", + title: "Claim review", + schema: CLAIM_REVIEW_SCHEMA, + batchSize: 8, + description: (n) => `Adversarially verify the ${n} claim\u2194evidence pair(s) of a construct SRD (skeptic fan-out; the orchestrator folds the verdicts and gates)`, + extraExpr: "'PAIRS=' + batch.join(',')", + applyHint: (engine, run) => [`node ${engine} review --apply verdicts.json --out ${run}`, `node ${engine} check --out ${run} --semantic`] + }, + "adr-judges": { + role: "adr-judge", + title: "Judge panel", + schema: ADR_JUDGE_SCHEMA, + batchSize: 1, + description: () => "Judge ONE contested ADR through the 3-lens panel (feasibility / operations & cost / user value); majority reduce", + extraExpr: "'LENS=' + batch[0] + '\\nADR = ' + JSON.stringify(ADR) + '\\nCITED EVIDENCE = ' + JSON.stringify(EVIDENCE)", + applyHint: (engine, run) => [`node ${engine} render --out ${run} --from-srd`] + }, + build: { + role: "builder", + title: "Build frontier", + schema: BUILDER_SCHEMA, + batchSize: 1, + description: (n) => `Build the ${n} ready BUILD-PLAN task(s) of this milestone frontier \u2014 one TDD builder per task, each in its own git worktree`, + extraExpr: "'TASK=' + batch.join(',')", + agentOpts: ", isolation: 'worktree'", + applyHint: (engine, run) => [`node ${engine} verify --out ${run}`] + } +}; +function phaseSpec(name) { + const spec = PHASE_SPECS[name]; + if (!spec) throw new Error(`no phase spec for "${name}"`); + return spec; +} +function toBatches(ids, batchSize) { + const out = []; + for (let i = 0; i < ids.length; i += batchSize) out.push(ids.slice(i, i + batchSize)); + return out; +} +var FOLD_PREAMBLE = { + research: [ + "// One-writer rule: this workflow only COLLECTS research fragments (summaries + URLs).", + "// The main agent folds them in serially with ONE pinned research re-run \u2014 a research run", + "// REBUILDS the dossier from exactly the angles/URLs it is given, so pass every angle \u2014", + "// then re-measures the gaps:" + ], + "claim-review": [ + "// One-writer rule: this workflow only COLLECTS verdict fragments. The main agent merges", + "// them into ONE verdicts.json (order-independent, keyed claimId::evidenceId \u2014 an omitted", + "// pair is reported unadjudicated, never silently passed), then folds and gates:" + ], + "adr-judges": [ + "// One-writer rule: this workflow only COLLECTS the 3 lens verdicts. The main agent", + "// majority-reduces them (pass = >=2 lenses scoring >=3): record one line per lens in the", + "// ADR's *Alternatives considered*, flip status proposed -> accepted in SRD.json only on a", + "// pass (on a fail, take the strongest rationale back to the user), then re-emit the tree:" + ], + build: [ + "// One-writer rule: builders write code ONLY in their own git worktrees. The main agent", + "// merges each worktree (serialising tasks that touch app-shared files \u2014 routing, schema,", + "// the test harness), folds artifacts/tests/status into BUILD-PLAN.json itself, then referees:" + ] +}; +function phaseWorkflowScript(ph, runAbs, engineAbs, units, adr) { + const spec = phaseSpec(ph.name); + const scriptPath = join15(runAbs, "orchestration", `${ph.name}.workflow.mjs`); + const meta = { name: `construct-${ph.name}`, description: spec.description(units.length), phases: [{ title: spec.title }] }; + const adrConsts = adr ? [`const ADR = ${JSON.stringify(adr.adr)}`, `const EVIDENCE = ${JSON.stringify(adr.evidence)}`] : []; + const tail = FOLD_PREAMBLE[ph.name] ?? []; + return [ + `export const meta = ${JSON.stringify(meta)}`, + ``, + `// NOT a plain Node script: launch via the Workflow tool \u2014 Workflow({ scriptPath: ${JSON.stringify(scriptPath)} }).`, + `// Emitted by \`construct orchestrate\` from the CURRENT run state. The run is the source`, + `// of truth: if its worklist changes, re-run \`orchestrate --phase ${ph.name}\` before launching.`, + ``, + `// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness).`, + `const RUN = ${JSON.stringify(runAbs)}`, + `const ENGINE = ${JSON.stringify(engineAbs)}`, + `const WORKLIST = ${JSON.stringify(ph.worklist)}`, + `const AGENTS = RUN + '/orchestration/agents'`, + `const BATCHES = ${JSON.stringify(toBatches(units, spec.batchSize))}`, + ...adrConsts, + `const SCHEMA = ${JSON.stringify(spec.schema)}`, + ``, + `function contract(name, extra) {`, + ` return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n'`, + ` + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n'`, + ` + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' \u2014 stdout drills and read-only commands only.'`, + ` + (extra ? '\\n' + extra : '')`, + `}`, + ``, + `log('construct ${ph.name}: ' + ${JSON.stringify(String(units.length))} + ' unit(s) across ' + BATCHES.length + ' agent(s)')`, + ``, + `phase(${JSON.stringify(spec.title)})`, + `const results = await pipeline(BATCHES, (batch, _item, i) =>`, + ` agent(contract('${spec.role}', ${spec.extraExpr}), { label: '${ph.name}:' + (i + 1), phase: ${JSON.stringify(spec.title)}, agentType: 'general-purpose', schema: SCHEMA${spec.agentOpts ?? ""} }))`, + ``, + ...tail, + ...spec.applyHint(engineAbs, runAbs).map((c) => `// ${c}`), + `return { phase: ${JSON.stringify(ph.name)}, worklist: WORKLIST, results: results.filter(Boolean) }`, + `` + ].join("\n"); +} +function agentContracts(runAbs, engineAbs, idea) { + const footer = oneWriterFooter(runAbs); + const builderFooter = oneWriterFooter( + 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)"; + return { + researcher: `# Contract: researcher + +You research evidence gaps of a construct run \u2014 the features, competitors, candidate tech and OSS seeds that \`analyze\` proved will render UNGROUNDED as-is (references/orchestration.md Pattern 1). + +Product one-liner: ${product} + +Your prompt lists your gaps (\`GAPS\`), each with its matching drill command. For EACH of your gaps: + +1. Run the drill (\`node ${engineAbs} web|oss|tech|so ... [--json]\`) and read the items. Drills print evidence to stdout and never write the dossier \u2014 they are safe to run in parallel. +2. Use your own WebSearch for what the drill misses (competitor pages, docs, issue threads, comparisons). +3. Judge relevance against the product one-liner and the gap \u2014 keep only what would actually ground this claim. + +Return (structured output): \`{ "findings": [{ "gap", "summary", "urls" }] }\` \u2014 your GAPS only. Per gap: a \u22645-line summary of what was found and why it matters to this product, and the URLs worth grounding, best first. The orchestrator folds ALL returned URLs into ONE pinned \`research\` re-run (a research run rebuilds the dossier from exactly the angles/URLs it is given), then re-runs \`analyze\`. +${footer}`, + "claim-reviewer": `# Contract: claim-reviewer + +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=\`). + +For EACH of your pairs: + +1. Read the pair's \`claim\` and its \`digest\` (the cited item's snippet). You may open the evidence source URL (see \`${join15(runAbs, "evidence", "EVIDENCE.md")}\`) for more context. A digest flagged \`[low-signal snippet \u2026]\` must be adjudicated skeptically \u2014 never grant \`supported\` on the URL alone. +2. Judge the claim\u2194evidence link: + - \`supported\` \u2014 the cited evidence directly backs the claim. + - \`partial\` \u2014 it backs a weaker version of the claim. + - \`unsupported\` \u2014 it is irrelevant / does not bear on the claim. + - \`refuted\` \u2014 it contradicts the claim. + When unsure, choose the HARSHER verdict \u2014 a false pass is worse than a false fail. +3. \`note\` is REQUIRED \u2014 \u2264200 chars grounded in what you actually read (quote or paraphrase the decisive text). + +Return (structured output): \`{ "pairs": [{ "claimId", "evidenceId", "verdict", "note" }] }\` \u2014 ids VERBATIM, your PAIRS only. The fold cross-checks the worklist: an invalid verdict token reads as unadjudicated (not as a failure) and an omitted pair is reported unadjudicated \u2014 never silently passed. +${footer}`, + "adr-judge": `# Contract: adr-judge + +You are ONE lens of a 3-judge panel over ONE contested ADR (references/orchestration.md Pattern 3). Your prompt carries your \`LENS\`, the \`ADR\` (title, context, decision, consequences, alternatives) and the \`CITED EVIDENCE\` snippets \u2014 pasted in; you do not need the run folder. + +The lenses: + +- \`feasibility\` \u2014 can this team build it in this timeline on this stack? +- \`operations-cost\` \u2014 what does it cost to run, observe, upgrade, exit? +- \`user-value\` \u2014 does this decision serve the stated users and value prop? + +Judge ONLY through your lens; the other two are someone else's job. If the ADR cites no evidence, judge from its text alone and say so in the rationale \u2014 that grounding gap is itself signal. + +Return (structured output): \`{ "lens", "score", "rationale" }\` \u2014 a 1\u20135 integer score and a one-paragraph rationale, nothing else. The orchestrator decides by majority (\u22652 judges scoring \u22653), records one line per lens in the ADR's *Alternatives considered*, and flips \`status: proposed \u2192 accepted\` only on a pass. +${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=\`). + +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). +3. Run the app's test command yourself in the worktree. Do NOT run \`verify\` or the milestone gate \u2014 the orchestrator referees after folding the whole frontier. +4. NEVER edit \`BUILD-PLAN.json\`, \`SRD.json\` or anything in the run folder, and never touch files another frontier task owns \u2014 app-shared files (routing, schema, the test harness) are serialised by the orchestrator. + +Return (structured output): \`{ "taskId", "status", "summary", "worktree", "artifacts", "tests", "blockers" }\` \u2014 \`status\` is \`done\` or \`blocked\`, \`worktree\` is the absolute path holding your committed work, \`artifacts\`/\`tests\` are app-relative. The orchestrator merges your worktree, folds artifacts/tests/status into BUILD-PLAN.json itself, and runs \`node ${engineAbs} verify --out ${runAbs}\`. +${builderFooter}` + }; +} +function runbookMd(phases, runAbs, engineAbs) { + const status = phases.map((p) => `| ${p.name} | \`${p.worklist}\` | ${p.ready ? `ready (${p.items} unit(s))` : "not ready"} | \`${p.prerequisite}\` |`).join("\n"); + const engine = `node ${engineAbs}`; + const agents = join15(runAbs, "orchestration", "agents"); + return `# construct \u2014 sequential RUNBOOK (eco / no-subagent fallback) + +Run: \`${runAbs}\` \xB7 Engine: \`${engine}\` + +Generated by \`construct orchestrate\` from the CURRENT run state. This sequential path is +correctness-identical to the multi-agent workflows \u2014 same worklists, same contracts, same +gates; only wall-clock differs. Fan-out is an optimization, not a requirement (the +three-tier model of references/orchestration.md). + +## Phase status + +| Phase | Worklist | Status | Produce it with | +|---|---|---|---| +${status} + +## The loop (play every role yourself, one unit at a time) + +1. **Interview \u2192 brief** (if not done): \`${engine} init --idea "" --out ${runAbs}\`, then fill \`${join15(runAbs, "brief.json")}\` one question at a time (references/interview-playbook.md). +2. **Research, then dig every gap** \u2014 \`${engine} research --out ${runAbs}\` builds the dossier; \`${engine} analyze --out ${runAbs}\` names each gap + its drill command. For EVERY gap, apply \`${join15(agents, "researcher.md")}\` yourself (run the drill, WebSearch what it misses, keep the URLs worth grounding). Fold in serially with ONE pinned re-run: \`${engine} research --out ${runAbs} --angles market,oss,tech --url \` \u2192 re-run \`analyze\`. Loop until clean or the user stops you. +3. **Render**: \`${engine} render --out ${runAbs} --level complex\`, then enrich the SRD (SKILL.md step 4). +4. **Claim-support review** \u2014 \`${engine} review --out ${runAbs}\` writes \`${join15(runAbs, "VERIFY.todo.json")}\`. For EVERY pair, apply \`${join15(agents, "claim-reviewer.md")}\` yourself (verdict + note into a \`verdicts.json\`). Then fold: \`${engine} review --apply verdicts.json --out ${runAbs}\` and gate: \`${engine} check --out ${runAbs} --semantic\` (must exit 0 before presenting). +5. **Judge panel \u2014 only for ONE genuinely contested ADR** \u2014 apply \`${join15(agents, "adr-judge.md")}\` yourself three times (feasibility / operations-cost / user-value) over the pasted ADR + its cited evidence. Majority (\u22652 lenses \u22653) \u2192 one line per lens under *Alternatives considered*, flip \`proposed \u2192 accepted\` in \`${join15(runAbs, "SRD.json")}\`, re-emit: \`${engine} render --out ${runAbs} --from-srd\`. +6. **Build the frontier** \u2014 per ready task (\`${engine} status --out ${runAbs} --json\` \u2192 \`frontier\`), apply \`${join15(agents, "builder.md")}\` yourself (sequentially you may work in the app dir directly \u2014 no worktree needed); fold artifacts/tests/status into \`${join15(runAbs, "BUILD-PLAN.json")}\`, then \`${engine} verify --out ${runAbs}\`. Milestone gate once the frontier is folded: \`${engine} verify --out ${runAbs} --run-tests --strict\`. + +The adversarial SRD review (Pattern 2) stays a single fresh-eyes pass by design \u2014 run it +per references/adversarial-review.md; it is deliberately not a fan-out and not emitted here. + +With subagents available, prefer the emitted workflows instead: \`orchestrate --out ${runAbs} --phase

\` then \`Workflow({ scriptPath: "${join15(runAbs, "orchestration", "

.workflow.mjs")}" })\` \u2014 you stay the sole writer either way. +`; +} + +// src/orchestrate.ts +var PHASES = ["research", "claim-review", "adr-judges", "build"]; +var SMALL_WORKLIST = 3; +function loadSrd(runDir) { + const manifest = srdManifestPath(runDir); + if (!existsSync11(manifest)) return null; + try { + const srd = JSON.parse(readFileSync10(manifest, "utf8")); + return srd && typeof srd === "object" ? srd : null; + } catch { + return null; + } +} +function loadDossier(runDir) { + const path = join16(runDir, "evidence", "evidence.json"); + if (!existsSync11(path)) return []; + try { + const data = JSON.parse(readFileSync10(path, "utf8")); + return Array.isArray(data) ? data.filter( + (e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.source === "string" + ) : []; + } catch { + return []; + } +} +function researchUnits(runDir, engineAbs) { + if (!existsSync11(join16(runDir, "brief.json")) || !existsSync11(join16(runDir, "evidence", "evidence.json"))) return null; + try { + const r = analyzeRun(runDir); + const labels = [ + ...r.ungroundedFeatures.map((f) => `feature (${f.priority}): "${f.title}" has no matchable evidence`), + ...r.unmatchedCompetitors.map((c) => `competitor: "${c}" never surfaced in market evidence`), + ...r.unmatchedTech.map((t) => `tech: "${t}" has no docs/StackOverflow grounding`), + ...r.unminedSeeds.map((s) => `oss seed: ${s} yielded no mined evidence`) + ]; + return labels.map((label, i) => { + const drill = r.suggestions[i]?.replace(/^construct /, `node ${engineAbs} `); + return drill ? `${label} \u2192 drill: ${drill}` : label; + }); + } catch { + return null; + } +} +function listPhases(runDir, engineAbs) { + const run = resolve3(runDir); + const gaps = researchUnits(run, engineAbs); + const todoPath = join16(run, "VERIFY.todo.json"); + let pairKeys = null; + if (existsSync11(todoPath)) { + try { + const todo = JSON.parse(readFileSync10(todoPath, "utf8")); + if (todo && Array.isArray(todo.pairs)) { + pairKeys = todo.pairs.filter((p) => !!p && typeof p.claimId === "string" && typeof p.evidenceId === "string").map((p) => `${p.claimId}::${p.evidenceId}`); + } + } catch { + } + } + const srd = loadSrd(run); + const adrIds = srd && Array.isArray(srd.architecture?.adrs) ? srd.architecture.adrs.map((a) => a.id) : []; + const plan = loadPlan(run); + const frontier = plan ? readyFrontier(plan).frontier : null; + const renderCmd = `node ${engineAbs} render --out ${run} --level complex`; + return [ + { + name: "research", + ready: gaps !== null, + worklist: join16(run, "evidence", "evidence.json"), + items: gaps?.length ?? 0, + ids: gaps ?? [], + prerequisite: `node ${engineAbs} research --out ${run}` + }, + { + name: "claim-review", + ready: pairKeys !== null, + worklist: todoPath, + items: pairKeys?.length ?? 0, + ids: pairKeys ?? [], + prerequisite: `node ${engineAbs} review --out ${run}` + }, + { + name: "adr-judges", + ready: adrIds.length > 0, + worklist: srdManifestPath(run), + items: adrIds.length, + ids: adrIds, + prerequisite: renderCmd + }, + { + name: "build", + ready: frontier !== null, + worklist: join16(run, "BUILD-PLAN.json"), + items: frontier?.length ?? 0, + ids: frontier ?? [], + prerequisite: renderCmd + } + ]; +} +function adrPanelPayload(runDir, adrId) { + const srd = loadSrd(runDir); + const adr = srd?.architecture?.adrs?.find((a) => a.id === adrId); + if (!adr) return null; + const byId = new Map(loadDossier(runDir).map((e) => [e.id, e])); + const evidence = [...new Set(adr.evidence)].map((id) => byId.get(id)).filter((e) => !!e).map((e) => ({ id: e.id, source: e.source, ref: e.ref, digest: (e.snippet || e.title || e.ref).slice(0, 600) })); + return { adr, evidence }; +} +var err = (exitCode, errors, phases) => ({ exitCode, written: [], notices: [], errors, phases }); +function orchestrateRun(runDir, engineAbs, opts = {}) { + const run = resolve3(runDir); + if (!existsSync11(run)) { + return err(2, [`run dir not found: ${run}`], []); + } + const phases = listPhases(run, engineAbs); + const adrPhase = phases.find((p) => p.name === "adr-judges"); + const notices = []; + let selected = phases.filter((p) => p.ready && p.name !== "adr-judges"); + let adrPayload; + if (opts.phase !== void 0) { + const ph = phases.find((p) => p.name === opts.phase); + if (!ph) { + return err(2, [`unknown phase "${opts.phase}" \u2014 expected one of: ${PHASES.join(", ")}.`], phases); + } + if (!ph.ready) { + return err(2, [`phase "${ph.name}" is not ready \u2014 its worklist ${ph.worklist} is missing or unreadable. Produce it first: ${ph.prerequisite}`], phases); + } + if (ph.name === "adr-judges") { + const available = `this run's ADRs: ${ph.ids.join(", ")}`; + if (!opts.adr) { + return err( + 2, + [ + `phase "adr-judges" panels ONE contested ADR \u2014 pass --adr (${available}). Reserve it for a genuinely contested, hard-to-reverse decision (references/orchestration.md Pattern 3).` + ], + phases + ); + } + if (!ph.ids.includes(opts.adr)) { + return err(2, [`ADR "${opts.adr}" not found \u2014 ${available}.`], phases); + } + adrPayload = adrPanelPayload(run, opts.adr) ?? void 0; + if (!adrPayload) return err(2, [`ADR "${opts.adr}" could not be loaded from ${ph.worklist}.`], phases); + } + selected = [ph]; + } else if (adrPhase.ready) { + notices.push( + `phase "adr-judges": not emitted by default (a 3-lens panel over ONE contested ADR) \u2014 emit it explicitly: orchestrate --out ${run} --phase adr-judges --adr (this run's ADRs: ${adrPhase.ids.join(", ")}).` + ); + } + const orchDir = join16(run, "orchestration"); + const agentsDir = join16(orchDir, "agents"); + mkdirSync6(join16(orchDir, "out"), { recursive: true }); + mkdirSync6(agentsDir, { recursive: true }); + const written = []; + let idea = ""; + try { + idea = loadBrief(run).idea; + } catch { + } + for (const [name, content] of Object.entries(agentContracts(run, engineAbs, idea))) { + const p = join16(agentsDir, `${name}.md`); + writeFileSync7(p, content); + written.push(p); + } + if (!opts.eco) { + for (const ph of selected) { + const units = ph.name === "adr-judges" ? [...ADR_LENSES] : ph.ids; + if (units.length === 0) { + notices.push(`phase "${ph.name}": worklist is empty \u2014 nothing to orchestrate.`); + continue; + } + if (ph.name !== "adr-judges" && units.length <= SMALL_WORKLIST) { + notices.push(`phase "${ph.name}": only ${units.length} unit(s) \u2014 the sequential --eco path is equivalent and cheaper.`); + } + const p = join16(orchDir, `${ph.name}.workflow.mjs`); + writeFileSync7(p, phaseWorkflowScript(ph, run, engineAbs, units, adrPayload)); + written.push(p); + } + } + const rb = join16(orchDir, "RUNBOOK.md"); + writeFileSync7(rb, runbookMd(phases, run, engineAbs)); + written.push(rb); + return { exitCode: 0, written, notices, errors: [], phases }; +} + // src/cli.ts var HELP = `construct v${VERSION} Turn a product idea into a grounded, buildable SRD suite. Interview \u2192 research @@ -4244,6 +4724,7 @@ Usage: construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app

] [--run-tests] [--strict] [--json] construct status --out [--json] + construct orchestrate --out [--phase research|claim-review|adr-judges|build] [--adr ] [--eco] [--list] construct semantic up|down|status Commands: @@ -4272,6 +4753,15 @@ Commands: verify Check a built app against BUILD-PLAN.json + the SRD (static by default; --run-tests executes the declared test commands). status Show what exists in a run (brief / evidence / SRD / check). + orchestrate Emit the run's multi-agent orchestration from its CURRENT state + into /orchestration/: one launchable workflow script per + ready fan-out phase (research gaps \xB7 claim-review pairs \xB7 the + adr-judges 3-lens panel \xB7 build frontier tasks), the dispatch + contracts (agents/.md) and a sequential RUNBOOK.md fallback. + Subagents RETURN fragments; you stay the sole writer of the run + folder (references/orchestration.md). Exits 2 when the named + phase's worklist does not exist yet \u2014 and says which command + produces it. Re-run after any worklist change (idempotent). semantic Manage the optional local Docker stack (Qdrant + Ollama + SearXNG). Options: @@ -4294,6 +4784,13 @@ Options: pairs (default: review ALL cited pairs; dropped pairs are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) + --phase For 'orchestrate': emit one phase only \u2014 + research | claim-review | adr-judges | build + --adr For 'orchestrate': the contested ADR the judge panel + rules on (required with --phase adr-judges) + --eco For 'orchestrate': emit only RUNBOOK.md + agents/*.md \u2014 + the explicit low-token sequential path + --list For 'orchestrate': print the phases + readiness as JSON --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS --web-engine auto | searxng | ddg | claude (default: auto) @@ -4327,6 +4824,7 @@ var COMMANDS = /* @__PURE__ */ new Set([ "verify", "review", "status", + "orchestrate", "semantic" ]); var VALUE_FLAGS = /* @__PURE__ */ new Set([ @@ -4346,9 +4844,11 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([ "min-grounding", "app", "apply", - "max-review" + "max-review", + "phase", + "adr" ]); -var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); +var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd", "eco", "list"]); function fail(message) { process.stderr.write(`construct: ${message} `); @@ -4436,7 +4936,7 @@ function csv(s) { function requireOut(p) { const out = (p.values.out || p.values.run || "").trim(); if (!out) fail("missing --out "); - return resolve3(out); + return resolve4(out); } var warnBrief = (w) => void process.stderr.write(` \u26A0 brief: ${w} `); @@ -4483,7 +4983,7 @@ async function main() { case "init": { const idea = p.values.idea; if (!idea) fail('missing --idea ""'); - const out = p.values.out ? resolve3(p.values.out) : resolve3(slugify(idea) || "construct-run"); + const out = p.values.out ? resolve4(p.values.out) : resolve4(slugify(idea) || "construct-run"); const brief = initBrief(idea, (/* @__PURE__ */ new Date()).toISOString()); const path = saveBrief(out, brief); process.stderr.write( @@ -4536,7 +5036,7 @@ async function main() { const c = brainstormCounts(b); process.stderr.write( [ - `construct: brainstorm board at ${join15(out, "BRAINSTORM.md")}`, + `construct: brainstorm board at ${join17(out, "BRAINSTORM.md")}`, ` ideas: ${b.ideas.length} (${c.kept} kept \xB7 ${c.parked} parked \xB7 ${c.proposed} proposed \xB7 ${c.rejected} rejected)`, ` next: generate ideas WITH the user (references/brainstorm-playbook.md), mark statuses in`, ` brainstorm.json, then: construct brainstorm --out ${out} --merge` @@ -4605,7 +5105,7 @@ async function main() { const r2 = renderFromSRD(out, { merge: p.bools.has("merge"), prd: p.bools.has("prd") }); process.stderr.write( [ - `construct: re-emitted the SRD tree from ${join15(out, "SRD.json")}`, + `construct: re-emitted the SRD tree from ${join17(out, "SRD.json")}`, ` files: ${r2.files.length} (${r2.srd.functional.length} FR \xB7 ${r2.srd.nonFunctional.length} NFR \xB7 ${r2.srd.architecture.adrs.length} ADR)`, ` next: construct check --out ${out}` ].join("\n") + "\n" @@ -4632,7 +5132,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); `construct: rendered the ${level} SRD for "${brief.idea}"`, ` files: ${r.files.length} (${r.srd.functional.length} FR \xB7 ${r.srd.nonFunctional.length} NFR \xB7 ${r.srd.architecture.adrs.length} ADR)`, ...design ? [` design: ${design.components.length} components \xB7 ${design.tokens.length} tokens \xB7 a11y ${design.accessibility.standard}`] : [], - ` manifest: ${join15(out, "SRD.json")}`, + ` manifest: ${join17(out, "SRD.json")}`, ` next: construct check --out ${out}` ].join("\n") + "\n" ); @@ -4670,7 +5170,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); case "review": { const out = requireOut(p); if (p.values.apply) { - const res = applyVerdicts(out, resolve3(p.values.apply)); + const res = applyVerdicts(out, resolve4(p.values.apply)); if (p.bools.has("json")) process.stdout.write(JSON.stringify(res, null, 2) + "\n"); else process.stdout.write(formatReviewReport(res) + "\n"); if (!res.ok) process.exit(1); @@ -4696,7 +5196,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); case "verify": { const out = requireOut(p); const res = verifyRun(out, { - appDir: p.values.app ? resolve3(p.values.app) : void 0, + appDir: p.values.app ? resolve4(p.values.app) : void 0, runTests: p.bools.has("run-tests"), strict: p.bools.has("strict") }); @@ -4715,7 +5215,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); process.stdout.write(JSON.stringify(plan ? readyFrontier(plan) : null, null, 2) + "\n"); return; } - const has = (rel) => existsSync11(join15(out, rel)) ? "\u2713" : "\xB7"; + const has = (rel) => existsSync12(join17(out, rel)) ? "\u2713" : "\xB7"; const planLine = plan ? ` \u2713 BUILD-PLAN.json (build: ${plan.tasks.filter((t) => t.status === "done").length}/${plan.tasks.length} tasks done)` : ` \xB7 BUILD-PLAN.json (build plan)`; const bs = loadBrainstorm(out); const bsLine = bs ? (() => { @@ -4735,6 +5235,52 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); ); return; } + case "orchestrate": { + const rawOut = (p.values.out || p.values.run || "").trim(); + if (!rawOut) { + process.stderr.write("construct orchestrate: --out is required (the run folder to orchestrate).\n"); + process.exit(2); + } + const runDir = resolve4(rawOut); + const engineAbs = realpathSync(fileURLToPath2(import.meta.url)); + if (p.bools.has("list")) { + if (!existsSync12(runDir)) { + process.stderr.write(`construct orchestrate: run dir not found: ${runDir}. +`); + process.exit(2); + } + process.stdout.write(JSON.stringify({ phases: listPhases(runDir, engineAbs) }, null, 2) + "\n"); + return; + } + const res = orchestrateRun(runDir, engineAbs, { + phase: p.values.phase, + adr: p.values.adr, + eco: p.bools.has("eco") + }); + if (res.exitCode !== 0) { + for (const e of res.errors) process.stderr.write(`construct orchestrate: ${e} +`); + process.exit(res.exitCode); + } + process.stderr.write("construct orchestrate: generated\n"); + for (const w of res.written) process.stderr.write(` ${w} +`); + for (const n of res.notices) process.stderr.write(`construct orchestrate: note \u2014 ${n} +`); + const workflows = res.written.filter((w) => w.endsWith(".workflow.mjs")); + if (workflows.length) { + process.stderr.write("\n"); + for (const w of workflows) process.stderr.write(`Launch: Workflow({ scriptPath: ${JSON.stringify(w)} }) +`); + process.stderr.write( + "Then fold the returned fragments in yourself and run the fold command named at the tail of each workflow (you stay the sole writer).\n" + ); + } else { + process.stderr.write(`Follow ${join17(runDir, "orchestration", "RUNBOOK.md")} sequentially (the eco path). +`); + } + return; + } case "semantic": { const action = p.positional[0] ?? "status"; const r = semanticControl(action); @@ -4745,10 +5291,10 @@ ${v.errors.map((e) => " - " + e).join("\n")}`); } } function loadEvidence4(runDir) { - const path = join15(runDir, "evidence", "evidence.json"); - if (!existsSync11(path)) return []; + const path = join17(runDir, "evidence", "evidence.json"); + if (!existsSync12(path)) return []; try { - const data = JSON.parse(readFileSync10(path, "utf8")); + const data = JSON.parse(readFileSync11(path, "utf8")); return Array.isArray(data) ? data.filter(isEvidenceItem) : []; } catch { return []; diff --git a/src/cli.ts b/src/cli.ts index a6ba092..49d177f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { analyzeRun, formatGapReport } from "./analyze.js"; import { verifyRun, formatVerifyReport } from "./verify.js"; import { runReview, applyVerdicts, formatReviewReport } from "./review.js"; import { loadPlan, readyFrontier } from "./plan.js"; +import { listPhases, orchestrateRun } from "./orchestrate.js"; import { semanticControl } from "./research/semantic.js"; const HELP = `construct v${VERSION} @@ -39,6 +40,7 @@ Usage: construct review --out [--apply ] [--max-review N] [--json] construct verify --out [--app ] [--run-tests] [--strict] [--json] construct status --out [--json] + construct orchestrate --out [--phase research|claim-review|adr-judges|build] [--adr ] [--eco] [--list] construct semantic up|down|status Commands: @@ -67,6 +69,15 @@ Commands: verify Check a built app against BUILD-PLAN.json + the SRD (static by default; --run-tests executes the declared test commands). status Show what exists in a run (brief / evidence / SRD / check). + orchestrate Emit the run's multi-agent orchestration from its CURRENT state + into /orchestration/: one launchable workflow script per + ready fan-out phase (research gaps · claim-review pairs · the + adr-judges 3-lens panel · build frontier tasks), the dispatch + contracts (agents/.md) and a sequential RUNBOOK.md fallback. + Subagents RETURN fragments; you stay the sole writer of the run + folder (references/orchestration.md). Exits 2 when the named + phase's worklist does not exist yet — and says which command + produces it. Re-run after any worklist change (idempotent). semantic Manage the optional local Docker stack (Qdrant + Ollama + SearXNG). Options: @@ -89,6 +100,13 @@ Options: pairs (default: review ALL cited pairs; dropped pairs are named in VERIFY.md) --app For 'verify': the built app directory (default: conventions.appDir) + --phase For 'orchestrate': emit one phase only — + research | claim-review | adr-judges | build + --adr For 'orchestrate': the contested ADR the judge panel + rules on (required with --phase adr-judges) + --eco For 'orchestrate': emit only RUNBOOK.md + agents/*.md — + the explicit low-token sequential path + --list For 'orchestrate': print the phases + readiness as JSON --run-tests For 'verify': also execute testCommand + per-task verify commands --strict For 'verify': a built must-have FR with no referencing test FAILS --web-engine auto | searxng | ddg | claude (default: auto) @@ -123,6 +141,7 @@ const COMMANDS = new Set([ "verify", "review", "status", + "orchestrate", "semantic", ]); const VALUE_FLAGS = new Set([ @@ -143,8 +162,10 @@ const VALUE_FLAGS = new Set([ "app", "apply", "max-review", + "phase", + "adr", ]); -const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd"]); +const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd", "allow-unverified", "from-srd", "eco", "list"]); function fail(message: string): never { process.stderr.write(`construct: ${message}\n`); @@ -581,6 +602,52 @@ async function main(): Promise { return; } + case "orchestrate": { + // Family exit-code contract (shared with the sibling skills): 2 when the + // run/phase is missing or not ready, naming the exact producing command. + const rawOut = (p.values.out || p.values.run || "").trim(); + if (!rawOut) { + process.stderr.write("construct orchestrate: --out is required (the run folder to orchestrate).\n"); + process.exit(2); + } + const runDir = resolve(rawOut); + // The engine's own absolute path — what the emitted artifacts tell + // subagents to invoke, valid from any cwd (realpath so a symlinked + // install still resolves). + const engineAbs = realpathSync(fileURLToPath(import.meta.url)); + if (p.bools.has("list")) { + if (!existsSync(runDir)) { + process.stderr.write(`construct orchestrate: run dir not found: ${runDir}.\n`); + process.exit(2); + } + process.stdout.write(JSON.stringify({ phases: listPhases(runDir, engineAbs) }, null, 2) + "\n"); + return; + } + const res = orchestrateRun(runDir, engineAbs, { + phase: p.values.phase, + adr: p.values.adr, + eco: p.bools.has("eco"), + }); + if (res.exitCode !== 0) { + for (const e of res.errors) process.stderr.write(`construct orchestrate: ${e}\n`); + process.exit(res.exitCode); + } + process.stderr.write("construct orchestrate: generated\n"); + for (const w of res.written) process.stderr.write(` ${w}\n`); + for (const n of res.notices) process.stderr.write(`construct orchestrate: note — ${n}\n`); + const workflows = res.written.filter((w) => w.endsWith(".workflow.mjs")); + if (workflows.length) { + process.stderr.write("\n"); + for (const w of workflows) process.stderr.write(`Launch: Workflow({ scriptPath: ${JSON.stringify(w)} })\n`); + process.stderr.write( + "Then fold the returned fragments in yourself and run the fold command named at the tail of each workflow (you stay the sole writer).\n", + ); + } else { + process.stderr.write(`Follow ${join(runDir, "orchestration", "RUNBOOK.md")} sequentially (the eco path).\n`); + } + return; + } + case "semantic": { const action = p.positional[0] ?? "status"; const r = semanticControl(action); diff --git a/src/orchestrate-templates.ts b/src/orchestrate-templates.ts new file mode 100644 index 0000000..0aad699 --- /dev/null +++ b/src/orchestrate-templates.ts @@ -0,0 +1,343 @@ +import { join } from "node:path"; +import type { AdrPanelPayload, PhaseInfo } from "./orchestrate.js"; + +// --------------------------------------------------------------------------- +// Templates for `construct orchestrate` — the generator that turns the run's +// CURRENT file-backed state into a launchable multi-agent Workflow per phase, +// the dispatch contracts it references, and a sequential RUNBOOK fallback. +// These emit the fan-out patterns of references/orchestration.md (Pattern 1 +// research, Pattern 3 judge panel, Pattern 4 claim-support, Pattern 5 build); +// Pattern 2 (adversarial review) is deliberately NOT emitted — it is ONE +// fresh-eyes reviewer by design, never a fan-out. +// Everything here is emitted by string concatenation with the run's constants +// injected as JSON literals, so the workflow runs as-is under the Workflow +// tool: `export const meta` stays a pure literal, and no emitted line ever +// calls Date.now()/Math.random()/new Date() (they throw in that harness). +// --------------------------------------------------------------------------- + +export const ADR_LENSES = ["feasibility", "operations-cost", "user-value"] as const; + +// Family-standard footer, reusing the one-writer rule of +// references/orchestration.md ("The serialization rule"): subagents return +// text; the orchestrator alone writes the run folder, serially. +function oneWriterFooter(runAbs: string, sanctionedWrite?: string): string { + return ` +## Return, don't write (the one-writer rule) + +Return ONLY the structured output specified above. Subagents NEVER write into the run folder: do not write, edit, or delete any file there, and do not run any engine command that writes it (\`research\`, \`review\`, \`review --apply\`, \`render\`, \`init\`, \`brainstorm --merge\`). Drill commands never write the dossier — \`web|oss|tech|so\` print evidence to stdout and are safe. The orchestrator is the sole writer: it folds your returned fragments in serially and runs the gates itself. One writer, many readers — no races, no clobbered evidence.${sanctionedWrite ? `\n\n${sanctionedWrite}` : ""} + +Exception for oversized prose: if a justification is too large to return, write ONLY to \`${join(runAbs, "orchestration", "out")}/-.md\` (a file namespaced to you alone) and return its path. +`; +} + +// Structured-output schemas the emitted workflows pass to agent(..., { schema }). +// The claim-review one mirrors what `review --apply` accepts ({ pairs: [...] }), +// so a fragment that validates here still gets re-checked (worklist +// cross-reference, invalid-token = unadjudicated) at fold time. +const RESEARCH_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" }, + }, + }, + }, + }, +}; + +const CLAIM_REVIEW_SCHEMA = { + type: "object", + required: ["pairs"], + properties: { + pairs: { + type: "array", + items: { + type: "object", + required: ["claimId", "evidenceId", "verdict", "note"], + properties: { + claimId: { type: "string", description: "verbatim from the worklist" }, + evidenceId: { type: "string", description: "verbatim from the worklist" }, + verdict: { enum: ["supported", "partial", "refuted", "unsupported"] }, + note: { type: "string", description: "<=200 chars, grounded in the digest/source you read" }, + }, + }, + }, + }, +}; + +const ADR_JUDGE_SCHEMA = { + type: "object", + required: ["lens", "score", "rationale"], + properties: { + lens: { enum: [...ADR_LENSES] }, + score: { type: "integer", minimum: 1, maximum: 5 }, + rationale: { type: "string", description: "one paragraph, nothing else" }, + }, +}; + +const BUILDER_SCHEMA = { + type: "object", + required: ["taskId", "status", "summary", "artifacts", "tests"], + properties: { + taskId: { type: "string" }, + status: { enum: ["done", "blocked"] }, + summary: { type: "string", description: "what was built, TDD evidence (RED then GREEN)" }, + worktree: { type: "string", description: "absolute path of your git worktree holding the committed work" }, + artifacts: { type: "array", items: { type: "string" }, description: "app-relative paths implementing the task" }, + tests: { type: "array", items: { type: "string" }, description: "app-relative test files (each names its FR id)" }, + blockers: { type: "array", items: { type: "string" } }, + }, +}; + +interface PhaseSpec { + role: string; + title: string; + schema: unknown; + /** One subagent per batch of at most this many units (1 = one agent per unit). */ + batchSize: number; + description: (n: number) => string; + /** The JS expression (workflow-side) building the per-batch prompt extra. */ + extraExpr: string; + /** Extra agent() options, e.g. the builder's sanctioned worktree isolation. */ + agentOpts?: string; + /** The orchestrator's fold step, shown as a tail comment + in the runbook. */ + applyHint: (engineAbs: string, runAbs: string) => string[]; +} + +const PHASE_SPECS: Record = { + research: { + role: "researcher", + title: "Research fan-out", + schema: RESEARCH_SCHEMA, + batchSize: 8, + description: (n) => `Research the ${n} evidence gap(s) construct analyze found (fan-out; the orchestrator folds URLs into ONE pinned research re-run)`, + extraExpr: "'GAPS (yours only, each with its drill command):\\n- ' + batch.join('\\n- ')", + applyHint: (engine, run) => [ + `node ${engine} research --out ${run} --angles market,oss,tech --url [--docs-url ]`, + `node ${engine} analyze --out ${run}`, + ], + }, + "claim-review": { + role: "claim-reviewer", + title: "Claim review", + schema: CLAIM_REVIEW_SCHEMA, + batchSize: 8, + description: (n) => + `Adversarially verify the ${n} claim↔evidence pair(s) of a construct SRD (skeptic fan-out; the orchestrator folds the verdicts and gates)`, + extraExpr: "'PAIRS=' + batch.join(',')", + applyHint: (engine, run) => [`node ${engine} review --apply verdicts.json --out ${run}`, `node ${engine} check --out ${run} --semantic`], + }, + "adr-judges": { + role: "adr-judge", + title: "Judge panel", + schema: ADR_JUDGE_SCHEMA, + batchSize: 1, + description: () => "Judge ONE contested ADR through the 3-lens panel (feasibility / operations & cost / user value); majority reduce", + extraExpr: "'LENS=' + batch[0] + '\\nADR = ' + JSON.stringify(ADR) + '\\nCITED EVIDENCE = ' + JSON.stringify(EVIDENCE)", + applyHint: (engine, run) => [`node ${engine} render --out ${run} --from-srd`], + }, + build: { + role: "builder", + title: "Build frontier", + schema: BUILDER_SCHEMA, + batchSize: 1, + description: (n) => `Build the ${n} ready BUILD-PLAN task(s) of this milestone frontier — one TDD builder per task, each in its own git worktree`, + extraExpr: "'TASK=' + batch.join(',')", + agentOpts: ", isolation: 'worktree'", + applyHint: (engine, run) => [`node ${engine} verify --out ${run}`], + }, +}; + +export function phaseSpec(name: string): PhaseSpec { + const spec = PHASE_SPECS[name]; + if (!spec) throw new Error(`no phase spec for "${name}"`); + return spec; +} + +/** Chunk worklist units into batches, one subagent per batch (order-preserving, deterministic). */ +export function toBatches(ids: string[], batchSize: number): string[][] { + const out: string[][] = []; + for (let i = 0; i < ids.length; i += batchSize) out.push(ids.slice(i, i + batchSize)); + return out; +} + +const FOLD_PREAMBLE: Record = { + research: [ + "// One-writer rule: this workflow only COLLECTS research fragments (summaries + URLs).", + "// The main agent folds them in serially with ONE pinned research re-run — a research run", + "// REBUILDS the dossier from exactly the angles/URLs it is given, so pass every angle —", + "// then re-measures the gaps:", + ], + "claim-review": [ + "// One-writer rule: this workflow only COLLECTS verdict fragments. The main agent merges", + "// them into ONE verdicts.json (order-independent, keyed claimId::evidenceId — an omitted", + "// pair is reported unadjudicated, never silently passed), then folds and gates:", + ], + "adr-judges": [ + "// One-writer rule: this workflow only COLLECTS the 3 lens verdicts. The main agent", + "// majority-reduces them (pass = >=2 lenses scoring >=3): record one line per lens in the", + "// ADR's *Alternatives considered*, flip status proposed -> accepted in SRD.json only on a", + "// pass (on a fail, take the strongest rationale back to the user), then re-emit the tree:", + ], + build: [ + "// One-writer rule: builders write code ONLY in their own git worktrees. The main agent", + "// merges each worktree (serialising tasks that touch app-shared files — routing, schema,", + "// the test harness), folds artifacts/tests/status into BUILD-PLAN.json itself, then referees:", + ], +}; + +export function phaseWorkflowScript(ph: PhaseInfo, runAbs: string, engineAbs: string, units: string[], adr?: AdrPanelPayload): string { + const spec = phaseSpec(ph.name); + const scriptPath = join(runAbs, "orchestration", `${ph.name}.workflow.mjs`); + const meta = { name: `construct-${ph.name}`, description: spec.description(units.length), phases: [{ title: spec.title }] }; + const adrConsts = adr ? [`const ADR = ${JSON.stringify(adr.adr)}`, `const EVIDENCE = ${JSON.stringify(adr.evidence)}`] : []; + const tail = FOLD_PREAMBLE[ph.name] ?? []; + return [ + `export const meta = ${JSON.stringify(meta)}`, + ``, + `// NOT a plain Node script: launch via the Workflow tool — Workflow({ scriptPath: ${JSON.stringify(scriptPath)} }).`, + `// Emitted by \`construct orchestrate\` from the CURRENT run state. The run is the source`, + `// of truth: if its worklist changes, re-run \`orchestrate --phase ${ph.name}\` before launching.`, + ``, + `// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness).`, + `const RUN = ${JSON.stringify(runAbs)}`, + `const ENGINE = ${JSON.stringify(engineAbs)}`, + `const WORKLIST = ${JSON.stringify(ph.worklist)}`, + `const AGENTS = RUN + '/orchestration/agents'`, + `const BATCHES = ${JSON.stringify(toBatches(units, spec.batchSize))}`, + ...adrConsts, + `const SCHEMA = ${JSON.stringify(spec.schema)}`, + ``, + `function contract(name, extra) {`, + ` return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n'`, + ` + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n'`, + ` + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' — stdout drills and read-only commands only.'`, + ` + (extra ? '\\n' + extra : '')`, + `}`, + ``, + `log('construct ${ph.name}: ' + ${JSON.stringify(String(units.length))} + ' unit(s) across ' + BATCHES.length + ' agent(s)')`, + ``, + `phase(${JSON.stringify(spec.title)})`, + `const results = await pipeline(BATCHES, (batch, _item, i) =>`, + ` agent(contract('${spec.role}', ${spec.extraExpr}), { label: '${ph.name}:' + (i + 1), phase: ${JSON.stringify(spec.title)}, agentType: 'general-purpose', schema: SCHEMA${spec.agentOpts ?? ""} }))`, + ``, + ...tail, + ...spec.applyHint(engineAbs, runAbs).map((c) => `// ${c}`), + `return { phase: ${JSON.stringify(ph.name)}, worklist: WORKLIST, results: results.filter(Boolean) }`, + ``, + ].join("\n"); +} + +export function agentContracts(runAbs: string, engineAbs: string, idea: string): Record { + const footer = oneWriterFooter(runAbs); + const builderFooter = oneWriterFooter( + 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)"; + return { + researcher: `# Contract: researcher + +You research evidence gaps of a construct run — the features, competitors, candidate tech and OSS seeds that \`analyze\` proved will render UNGROUNDED as-is (references/orchestration.md Pattern 1). + +Product one-liner: ${product} + +Your prompt lists your gaps (\`GAPS\`), each with its matching drill command. For EACH of your gaps: + +1. Run the drill (\`node ${engineAbs} web|oss|tech|so ... [--json]\`) and read the items. Drills print evidence to stdout and never write the dossier — they are safe to run in parallel. +2. Use your own WebSearch for what the drill misses (competitor pages, docs, issue threads, comparisons). +3. Judge relevance against the product one-liner and the gap — keep only what would actually ground this claim. + +Return (structured output): \`{ "findings": [{ "gap", "summary", "urls" }] }\` — your GAPS only. Per gap: a ≤5-line summary of what was found and why it matters to this product, and the URLs worth grounding, best first. The orchestrator folds ALL returned URLs into ONE pinned \`research\` re-run (a research run rebuilds the dossier from exactly the angles/URLs it is given), then re-runs \`analyze\`. +${footer}`, + "claim-reviewer": `# Contract: claim-reviewer + +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=\`). + +For EACH of your pairs: + +1. Read the pair's \`claim\` and its \`digest\` (the cited item's snippet). You may open the evidence source URL (see \`${join(runAbs, "evidence", "EVIDENCE.md")}\`) for more context. A digest flagged \`[low-signal snippet …]\` must be adjudicated skeptically — never grant \`supported\` on the URL alone. +2. Judge the claim↔evidence link: + - \`supported\` — the cited evidence directly backs the claim. + - \`partial\` — it backs a weaker version of the claim. + - \`unsupported\` — it is irrelevant / does not bear on the claim. + - \`refuted\` — it contradicts the claim. + When unsure, choose the HARSHER verdict — a false pass is worse than a false fail. +3. \`note\` is REQUIRED — ≤200 chars grounded in what you actually read (quote or paraphrase the decisive text). + +Return (structured output): \`{ "pairs": [{ "claimId", "evidenceId", "verdict", "note" }] }\` — ids VERBATIM, your PAIRS only. The fold cross-checks the worklist: an invalid verdict token reads as unadjudicated (not as a failure) and an omitted pair is reported unadjudicated — never silently passed. +${footer}`, + "adr-judge": `# Contract: adr-judge + +You are ONE lens of a 3-judge panel over ONE contested ADR (references/orchestration.md Pattern 3). Your prompt carries your \`LENS\`, the \`ADR\` (title, context, decision, consequences, alternatives) and the \`CITED EVIDENCE\` snippets — pasted in; you do not need the run folder. + +The lenses: + +- \`feasibility\` — can this team build it in this timeline on this stack? +- \`operations-cost\` — what does it cost to run, observe, upgrade, exit? +- \`user-value\` — does this decision serve the stated users and value prop? + +Judge ONLY through your lens; the other two are someone else's job. If the ADR cites no evidence, judge from its text alone and say so in the rationale — that grounding gap is itself signal. + +Return (structured output): \`{ "lens", "score", "rationale" }\` — a 1–5 integer score and a one-paragraph rationale, nothing else. The orchestrator decides by majority (≥2 judges scoring ≥3), records one line per lens in the ADR's *Alternatives considered*, and flips \`status: proposed → accepted\` only on a pass. +${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=\`). + +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). +3. Run the app's test command yourself in the worktree. Do NOT run \`verify\` or the milestone gate — the orchestrator referees after folding the whole frontier. +4. NEVER edit \`BUILD-PLAN.json\`, \`SRD.json\` or anything in the run folder, and never touch files another frontier task owns — app-shared files (routing, schema, the test harness) are serialised by the orchestrator. + +Return (structured output): \`{ "taskId", "status", "summary", "worktree", "artifacts", "tests", "blockers" }\` — \`status\` is \`done\` or \`blocked\`, \`worktree\` is the absolute path holding your committed work, \`artifacts\`/\`tests\` are app-relative. The orchestrator merges your worktree, folds artifacts/tests/status into BUILD-PLAN.json itself, and runs \`node ${engineAbs} verify --out ${runAbs}\`. +${builderFooter}`, + }; +} + +export function runbookMd(phases: PhaseInfo[], runAbs: string, engineAbs: string): string { + const status = phases + .map((p) => `| ${p.name} | \`${p.worklist}\` | ${p.ready ? `ready (${p.items} unit(s))` : "not ready"} | \`${p.prerequisite}\` |`) + .join("\n"); + const engine = `node ${engineAbs}`; + const agents = join(runAbs, "orchestration", "agents"); + return `# construct — sequential RUNBOOK (eco / no-subagent fallback) + +Run: \`${runAbs}\` · Engine: \`${engine}\` + +Generated by \`construct orchestrate\` from the CURRENT run state. This sequential path is +correctness-identical to the multi-agent workflows — same worklists, same contracts, same +gates; only wall-clock differs. Fan-out is an optimization, not a requirement (the +three-tier model of references/orchestration.md). + +## Phase status + +| Phase | Worklist | Status | Produce it with | +|---|---|---|---| +${status} + +## The loop (play every role yourself, one unit at a time) + +1. **Interview → brief** (if not done): \`${engine} init --idea "" --out ${runAbs}\`, then fill \`${join(runAbs, "brief.json")}\` one question at a time (references/interview-playbook.md). +2. **Research, then dig every gap** — \`${engine} research --out ${runAbs}\` builds the dossier; \`${engine} analyze --out ${runAbs}\` names each gap + its drill command. For EVERY gap, apply \`${join(agents, "researcher.md")}\` yourself (run the drill, WebSearch what it misses, keep the URLs worth grounding). Fold in serially with ONE pinned re-run: \`${engine} research --out ${runAbs} --angles market,oss,tech --url \` → re-run \`analyze\`. Loop until clean or the user stops you. +3. **Render**: \`${engine} render --out ${runAbs} --level complex\`, then enrich the SRD (SKILL.md step 4). +4. **Claim-support review** — \`${engine} review --out ${runAbs}\` writes \`${join(runAbs, "VERIFY.todo.json")}\`. For EVERY pair, apply \`${join(agents, "claim-reviewer.md")}\` yourself (verdict + note into a \`verdicts.json\`). Then fold: \`${engine} review --apply verdicts.json --out ${runAbs}\` and gate: \`${engine} check --out ${runAbs} --semantic\` (must exit 0 before presenting). +5. **Judge panel — only for ONE genuinely contested ADR** — apply \`${join(agents, "adr-judge.md")}\` yourself three times (feasibility / operations-cost / user-value) over the pasted ADR + its cited evidence. Majority (≥2 lenses ≥3) → one line per lens under *Alternatives considered*, flip \`proposed → accepted\` in \`${join(runAbs, "SRD.json")}\`, re-emit: \`${engine} render --out ${runAbs} --from-srd\`. +6. **Build the frontier** — per ready task (\`${engine} status --out ${runAbs} --json\` → \`frontier\`), apply \`${join(agents, "builder.md")}\` yourself (sequentially you may work in the app dir directly — no worktree needed); fold artifacts/tests/status into \`${join(runAbs, "BUILD-PLAN.json")}\`, then \`${engine} verify --out ${runAbs}\`. Milestone gate once the frontier is folded: \`${engine} verify --out ${runAbs} --run-tests --strict\`. + +The adversarial SRD review (Pattern 2) stays a single fresh-eyes pass by design — run it +per references/adversarial-review.md; it is deliberately not a fan-out and not emitted here. + +With subagents available, prefer the emitted workflows instead: \`orchestrate --out ${runAbs} --phase

\` then \`Workflow({ scriptPath: "${join(runAbs, "orchestration", "

.workflow.mjs")}" })\` — you stay the sole writer either way. +`; +} diff --git a/src/orchestrate.ts b/src/orchestrate.ts new file mode 100644 index 0000000..8a6a8b4 --- /dev/null +++ b/src/orchestrate.ts @@ -0,0 +1,295 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { analyzeRun } from "./analyze.js"; +import { loadBrief } from "./brief.js"; +import { ADR_LENSES, agentContracts, phaseWorkflowScript, runbookMd } from "./orchestrate-templates.js"; +import { loadPlan, readyFrontier } from "./plan.js"; +import { srdManifestPath } from "./srd.js"; +import type { ADR, ClaimEvidencePair, EvidenceItem, SRD } from "./types.js"; + +// --------------------------------------------------------------------------- +// `construct orchestrate` — emit the run's multi-agent orchestration from its +// CURRENT file-backed state (per-phase workflow scripts + dispatch contracts + +// a sequential RUNBOOK), so a subagent-capable harness fans the judgment work +// out while the main agent stays the sole writer. It mechanises the fan-out +// patterns of references/orchestration.md: Pattern 1 (research, one researcher +// per `analyze` gap), Pattern 4 (claim-review, one skeptic per claim↔evidence +// pair), Pattern 3 (adr-judges, the fixed 3-lens panel over ONE contested +// ADR), Pattern 5 (build, one worktree-isolated builder per ready task). +// Pattern 2 (adversarial review), the interview and the brainstorm are +// deliberately NOT emitted — they are single-role by design. +// Per-phase emission is deliberate: each worklist only exists after its engine +// step (`research`, `review`, `render`), so a whole-pipeline script could only +// carry placeholders — exactly what the check gates exist to prevent. +// --------------------------------------------------------------------------- + +export const PHASES = ["research", "claim-review", "adr-judges", "build"] as const; +export type PhaseName = (typeof PHASES)[number]; + +/** Small worklists don't amortize a fan-out — orchestrate says so and nudges --eco. */ +export const SMALL_WORKLIST = 3; +/** One subagent per batch of at most this many worklist units (build/adr-judges use 1). */ +export const BATCH_SIZE = 8; + +export interface PhaseInfo { + name: PhaseName; + ready: boolean; + /** Absolute path of the file-backed state this phase fans out over. */ + worklist: string; + items: number; + /** + * The injected fan-out units: gap+drill lines for research, claimId::evidenceId + * keys for claim-review, the run's ADR ids for adr-judges (the emitted fan-out + * is the 3 lenses over ONE of them), ready task ids for build. + */ + ids: string[]; + /** The engine command that produces the worklist when it is missing. */ + prerequisite: string; +} + +/** The ADR + cited-evidence snippets pasted into the adr-judges workflow (Pattern 3: no run-folder access). */ +export interface AdrPanelPayload { + adr: ADR; + evidence: { id: string; source: string; ref: string; digest: string }[]; +} + +function loadSrd(runDir: string): SRD | null { + const manifest = srdManifestPath(runDir); + if (!existsSync(manifest)) return null; + try { + const srd = JSON.parse(readFileSync(manifest, "utf8")) as SRD; + return srd && typeof srd === "object" ? srd : null; + } catch { + return null; + } +} + +function loadDossier(runDir: string): EvidenceItem[] { + const path = join(runDir, "evidence", "evidence.json"); + if (!existsSync(path)) return []; + try { + const data = JSON.parse(readFileSync(path, "utf8")) as unknown; + return Array.isArray(data) + ? (data.filter( + (e) => !!e && typeof e === "object" && typeof (e as { id?: unknown }).id === "string" && typeof (e as { source?: unknown }).source === "string", + ) as EvidenceItem[]) + : []; + } catch { + return []; + } +} + +// The research fan-out units: one line per `analyze` gap, carrying the gap AND +// its drill command (rewritten onto the absolute engine path). Reuses analyzeRun +// itself — analyze prints its report instead of persisting it, so orchestrate +// derives the gaps from the same internal function rather than duplicating the +// matcher logic. analyzeRun emits exactly one drill suggestion per gap, in this +// same order (features → competitors → tech → seeds). +function researchUnits(runDir: string, engineAbs: string): string[] | null { + if (!existsSync(join(runDir, "brief.json")) || !existsSync(join(runDir, "evidence", "evidence.json"))) return null; + try { + const r = analyzeRun(runDir); + const labels = [ + ...r.ungroundedFeatures.map((f) => `feature (${f.priority}): "${f.title}" has no matchable evidence`), + ...r.unmatchedCompetitors.map((c) => `competitor: "${c}" never surfaced in market evidence`), + ...r.unmatchedTech.map((t) => `tech: "${t}" has no docs/StackOverflow grounding`), + ...r.unminedSeeds.map((s) => `oss seed: ${s} yielded no mined evidence`), + ]; + return labels.map((label, i) => { + const drill = r.suggestions[i]?.replace(/^construct /, `node ${engineAbs} `); + return drill ? `${label} → drill: ${drill}` : label; + }); + } catch { + return null; // unreadable brief = not ready + } +} + +export function listPhases(runDir: string, engineAbs: string): PhaseInfo[] { + const run = resolve(runDir); + + const gaps = researchUnits(run, engineAbs); + + const todoPath = join(run, "VERIFY.todo.json"); + let pairKeys: string[] | null = null; + if (existsSync(todoPath)) { + try { + const todo = JSON.parse(readFileSync(todoPath, "utf8")) as { pairs?: ClaimEvidencePair[] }; + if (todo && Array.isArray(todo.pairs)) { + pairKeys = todo.pairs + .filter((p) => !!p && typeof p.claimId === "string" && typeof p.evidenceId === "string") + .map((p) => `${p.claimId}::${p.evidenceId}`); + } + } catch { + /* unreadable worklist = not ready */ + } + } + + const srd = loadSrd(run); + const adrIds = srd && Array.isArray(srd.architecture?.adrs) ? srd.architecture.adrs.map((a) => a.id) : []; + + const plan = loadPlan(run); + const frontier = plan ? readyFrontier(plan).frontier : null; + + const renderCmd = `node ${engineAbs} render --out ${run} --level complex`; + return [ + { + name: "research", + ready: gaps !== null, + worklist: join(run, "evidence", "evidence.json"), + items: gaps?.length ?? 0, + ids: gaps ?? [], + prerequisite: `node ${engineAbs} research --out ${run}`, + }, + { + name: "claim-review", + ready: pairKeys !== null, + worklist: todoPath, + items: pairKeys?.length ?? 0, + ids: pairKeys ?? [], + prerequisite: `node ${engineAbs} review --out ${run}`, + }, + { + name: "adr-judges", + ready: adrIds.length > 0, + worklist: srdManifestPath(run), + items: adrIds.length, + ids: adrIds, + prerequisite: renderCmd, + }, + { + name: "build", + ready: frontier !== null, + worklist: join(run, "BUILD-PLAN.json"), + items: frontier?.length ?? 0, + ids: frontier ?? [], + prerequisite: renderCmd, + }, + ]; +} + +// Pattern 3 pastes its inputs into the branches (a judge must not need the run +// folder): the ADR verbatim + each cited [E#] item's snippet, capped like the +// review digests so one huge snippet can't blow the prompt up. +function adrPanelPayload(runDir: string, adrId: string): AdrPanelPayload | null { + const srd = loadSrd(runDir); + const adr = srd?.architecture?.adrs?.find((a) => a.id === adrId); + if (!adr) return null; + const byId = new Map(loadDossier(runDir).map((e) => [e.id, e] as const)); + const evidence = [...new Set(adr.evidence)] + .map((id) => byId.get(id)) + .filter((e): e is EvidenceItem => !!e) + .map((e) => ({ id: e.id, source: e.source, ref: e.ref, digest: (e.snippet || e.title || e.ref).slice(0, 600) })); + return { adr, evidence }; +} + +export interface OrchestrateOptions { + /** Emit only this phase (exit 2 if its worklist does not exist yet). */ + phase?: string; + /** The contested ADR to panel — required with `--phase adr-judges`. */ + adr?: string; + /** Emit only the RUNBOOK + contracts (the explicit low-token sequential path). */ + eco?: boolean; +} + +export interface OrchestrateResult { + exitCode: number; + written: string[]; + notices: string[]; + errors: string[]; + phases: PhaseInfo[]; +} + +const err = (exitCode: number, errors: string[], phases: PhaseInfo[]): OrchestrateResult => ({ exitCode, written: [], notices: [], errors, phases }); + +export function orchestrateRun(runDir: string, engineAbs: string, opts: OrchestrateOptions = {}): OrchestrateResult { + const run = resolve(runDir); + if (!existsSync(run)) { + return err(2, [`run dir not found: ${run}`], []); + } + const phases = listPhases(run, engineAbs); + const adrPhase = phases.find((p) => p.name === "adr-judges")!; + + const notices: string[] = []; + // The judge panel is opt-in (token-expensive, ONE contested ADR at a time — + // references/orchestration.md Pattern 3 + budget guidance), so the default + // emission covers the three worklist-driven fan-outs and names the flag. + let selected = phases.filter((p) => p.ready && p.name !== "adr-judges"); + let adrPayload: AdrPanelPayload | undefined; + + if (opts.phase !== undefined) { + const ph = phases.find((p) => p.name === opts.phase); + if (!ph) { + return err(2, [`unknown phase "${opts.phase}" — expected one of: ${PHASES.join(", ")}.`], phases); + } + if (!ph.ready) { + return err(2, [`phase "${ph.name}" is not ready — its worklist ${ph.worklist} is missing or unreadable. Produce it first: ${ph.prerequisite}`], phases); + } + if (ph.name === "adr-judges") { + const available = `this run's ADRs: ${ph.ids.join(", ")}`; + if (!opts.adr) { + return err( + 2, + [ + `phase "adr-judges" panels ONE contested ADR — pass --adr (${available}). Reserve it for a genuinely contested, hard-to-reverse decision (references/orchestration.md Pattern 3).`, + ], + phases, + ); + } + if (!ph.ids.includes(opts.adr)) { + return err(2, [`ADR "${opts.adr}" not found — ${available}.`], phases); + } + adrPayload = adrPanelPayload(run, opts.adr) ?? undefined; + if (!adrPayload) return err(2, [`ADR "${opts.adr}" could not be loaded from ${ph.worklist}.`], phases); + } + selected = [ph]; + } else if (adrPhase.ready) { + notices.push( + `phase "adr-judges": not emitted by default (a 3-lens panel over ONE contested ADR) — emit it explicitly: orchestrate --out ${run} --phase adr-judges --adr (this run's ADRs: ${adrPhase.ids.join(", ")}).`, + ); + } + + const orchDir = join(run, "orchestration"); + const agentsDir = join(orchDir, "agents"); + mkdirSync(join(orchDir, "out"), { recursive: true }); + mkdirSync(agentsDir, { recursive: true }); + + const written: string[] = []; + + // Contracts: every role, every call (idempotent overwrite) — they double as the + // RUNBOOK's self-pass checklists, so eco mode needs them too. + let idea = ""; + try { + idea = loadBrief(run).idea; + } catch { + /* no brief yet — the contract says so */ + } + for (const [name, content] of Object.entries(agentContracts(run, engineAbs, idea))) { + const p = join(agentsDir, `${name}.md`); + writeFileSync(p, content); + written.push(p); + } + + if (!opts.eco) { + for (const ph of selected) { + // adr-judges fans out over the 3 lenses of the ONE chosen ADR; the other + // phases fan out over their worklist units. + const units = ph.name === "adr-judges" ? [...ADR_LENSES] : ph.ids; + if (units.length === 0) { + notices.push(`phase "${ph.name}": worklist is empty — nothing to orchestrate.`); + continue; + } + if (ph.name !== "adr-judges" && units.length <= SMALL_WORKLIST) { + notices.push(`phase "${ph.name}": only ${units.length} unit(s) — the sequential --eco path is equivalent and cheaper.`); + } + const p = join(orchDir, `${ph.name}.workflow.mjs`); + writeFileSync(p, phaseWorkflowScript(ph, run, engineAbs, units, adrPayload)); + written.push(p); + } + } + + const rb = join(orchDir, "RUNBOOK.md"); + writeFileSync(rb, runbookMd(phases, run, engineAbs)); + written.push(rb); + + return { exitCode: 0, written, notices, errors: [], phases }; +} diff --git a/tests/__snapshots__/orchestrate.test.ts.snap b/tests/__snapshots__/orchestrate.test.ts.snap new file mode 100644 index 0000000..36e422a --- /dev/null +++ b/tests/__snapshots__/orchestrate.test.ts.snap @@ -0,0 +1,141 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`orchestrate — contracts & runbook > golden shape (paths normalized) > RUNBOOK.md 1`] = ` +"# construct — sequential RUNBOOK (eco / no-subagent fallback) + +Run: \`\` · Engine: \`node \` + +Generated by \`construct orchestrate\` from the CURRENT run state. This sequential path is +correctness-identical to the multi-agent workflows — same worklists, same contracts, same +gates; only wall-clock differs. Fan-out is an optimization, not a requirement (the +three-tier model of references/orchestration.md). + +## Phase status + +| Phase | Worklist | Status | Produce it with | +|---|---|---|---| +| research | \`/evidence/evidence.json\` | ready (6 unit(s)) | \`node research --out \` | +| claim-review | \`/VERIFY.todo.json\` | ready (6 unit(s)) | \`node review --out \` | +| adr-judges | \`/SRD.json\` | ready (3 unit(s)) | \`node render --out --level complex\` | +| build | \`/BUILD-PLAN.json\` | ready (4 unit(s)) | \`node render --out --level complex\` | + +## The loop (play every role yourself, one unit at a time) + +1. **Interview → brief** (if not done): \`node init --idea "" --out \`, then fill \`/brief.json\` one question at a time (references/interview-playbook.md). +2. **Research, then dig every gap** — \`node research --out \` builds the dossier; \`node analyze --out \` names each gap + its drill command. For EVERY gap, apply \`/orchestration/agents/researcher.md\` yourself (run the drill, WebSearch what it misses, keep the URLs worth grounding). Fold in serially with ONE pinned re-run: \`node research --out --angles market,oss,tech --url \` → re-run \`analyze\`. Loop until clean or the user stops you. +3. **Render**: \`node render --out --level complex\`, then enrich the SRD (SKILL.md step 4). +4. **Claim-support review** — \`node review --out \` writes \`/VERIFY.todo.json\`. For EVERY pair, apply \`/orchestration/agents/claim-reviewer.md\` yourself (verdict + note into a \`verdicts.json\`). Then fold: \`node review --apply verdicts.json --out \` and gate: \`node check --out --semantic\` (must exit 0 before presenting). +5. **Judge panel — only for ONE genuinely contested ADR** — apply \`/orchestration/agents/adr-judge.md\` yourself three times (feasibility / operations-cost / user-value) over the pasted ADR + its cited evidence. Majority (≥2 lenses ≥3) → one line per lens under *Alternatives considered*, flip \`proposed → accepted\` in \`/SRD.json\`, re-emit: \`node render --out --from-srd\`. +6. **Build the frontier** — per ready task (\`node status --out --json\` → \`frontier\`), apply \`/orchestration/agents/builder.md\` yourself (sequentially you may work in the app dir directly — no worktree needed); fold artifacts/tests/status into \`/BUILD-PLAN.json\`, then \`node verify --out \`. Milestone gate once the frontier is folded: \`node verify --out --run-tests --strict\`. + +The adversarial SRD review (Pattern 2) stays a single fresh-eyes pass by design — run it +per references/adversarial-review.md; it is deliberately not a fan-out and not emitted here. + +With subagents available, prefer the emitted workflows instead: \`orchestrate --out --phase

\` then \`Workflow({ scriptPath: "/orchestration/

.workflow.mjs" })\` — you stay the sole writer either way. +" +`; + +exports[`orchestrate — contracts & runbook > golden shape (paths normalized) > adr-judges.workflow.mjs 1`] = ` +"export const meta = {"name":"construct-adr-judges","description":"Judge ONE contested ADR through the 3-lens panel (feasibility / operations & cost / user value); majority reduce","phases":[{"title":"Judge panel"}]} + +// NOT a plain Node script: launch via the Workflow tool — Workflow({ scriptPath: "/orchestration/adr-judges.workflow.mjs" }). +// Emitted by \`construct orchestrate\` from the CURRENT run state. The run is the source +// of truth: if its worklist changes, re-run \`orchestrate --phase adr-judges\` before launching. + +// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness). +const RUN = "" +const ENGINE = "" +const WORKLIST = "/SRD.json" +const AGENTS = RUN + '/orchestration/agents' +const BATCHES = [["feasibility"],["operations-cost"],["user-value"]] +const ADR = {"id":"0003","title":"Data persistence and integration approach","status":"proposed","context":"\\"Readpile\\" must persist state and integrate with external services (calendar/email and similar) reliably.","decision":"Use a single primary datastore with explicit, versioned integration boundaries for each external service.","consequences":"A clear data-ownership model; integrations are testable in isolation behind an adapter. Cross-service consistency must be designed explicitly.","alternatives":"A polyglot-persistence or event-sourced approach was considered; deferred until scale demands it.","evidence":[]} +const EVIDENCE = [] +const SCHEMA = {"type":"object","required":["lens","score","rationale"],"properties":{"lens":{"enum":["feasibility","operations-cost","user-value"]},"score":{"type":"integer","minimum":1,"maximum":5},"rationale":{"type":"string","description":"one paragraph, nothing else"}}} + +function contract(name, extra) { + return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n' + + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n' + + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' — stdout drills and read-only commands only.' + + (extra ? '\\n' + extra : '') +} + +log('construct adr-judges: ' + "3" + ' unit(s) across ' + BATCHES.length + ' agent(s)') + +phase("Judge panel") +const results = await pipeline(BATCHES, (batch, _item, i) => + agent(contract('adr-judge', 'LENS=' + batch[0] + '\\nADR = ' + JSON.stringify(ADR) + '\\nCITED EVIDENCE = ' + JSON.stringify(EVIDENCE)), { label: 'adr-judges:' + (i + 1), phase: "Judge panel", agentType: 'general-purpose', schema: SCHEMA })) + +// One-writer rule: this workflow only COLLECTS the 3 lens verdicts. The main agent +// majority-reduces them (pass = >=2 lenses scoring >=3): record one line per lens in the +// ADR's *Alternatives considered*, flip status proposed -> accepted in SRD.json only on a +// pass (on a fail, take the strongest rationale back to the user), then re-emit the tree: +// node render --out --from-srd +return { phase: "adr-judges", worklist: WORKLIST, results: results.filter(Boolean) } +" +`; + +exports[`orchestrate — contracts & runbook > golden shape (paths normalized) > claim-reviewer.md 1`] = ` +"# Contract: claim-reviewer + +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=\`). + +For EACH of your pairs: + +1. Read the pair's \`claim\` and its \`digest\` (the cited item's snippet). You may open the evidence source URL (see \`/evidence/EVIDENCE.md\`) for more context. A digest flagged \`[low-signal snippet …]\` must be adjudicated skeptically — never grant \`supported\` on the URL alone. +2. Judge the claim↔evidence link: + - \`supported\` — the cited evidence directly backs the claim. + - \`partial\` — it backs a weaker version of the claim. + - \`unsupported\` — it is irrelevant / does not bear on the claim. + - \`refuted\` — it contradicts the claim. + When unsure, choose the HARSHER verdict — a false pass is worse than a false fail. +3. \`note\` is REQUIRED — ≤200 chars grounded in what you actually read (quote or paraphrase the decisive text). + +Return (structured output): \`{ "pairs": [{ "claimId", "evidenceId", "verdict", "note" }] }\` — ids VERBATIM, your PAIRS only. The fold cross-checks the worklist: an invalid verdict token reads as unadjudicated (not as a failure) and an omitted pair is reported unadjudicated — never silently passed. + +## Return, don't write (the one-writer rule) + +Return ONLY the structured output specified above. Subagents NEVER write into the run folder: do not write, edit, or delete any file there, and do not run any engine command that writes it (\`research\`, \`review\`, \`review --apply\`, \`render\`, \`init\`, \`brainstorm --merge\`). Drill commands never write the dossier — \`web|oss|tech|so\` print evidence to stdout and are safe. The orchestrator is the sole writer: it folds your returned fragments in serially and runs the gates itself. One writer, many readers — no races, no clobbered evidence. + +Exception for oversized prose: if a justification is too large to return, write ONLY to \`/orchestration/out/-.md\` (a file namespaced to you alone) and return its path. +" +`; + +exports[`orchestrate — contracts & runbook > golden shape (paths normalized) > research.workflow.mjs 1`] = ` +"export const meta = {"name":"construct-research","description":"Research the 6 evidence gap(s) construct analyze found (fan-out; the orchestrator folds URLs into ONE pinned research re-run)","phases":[{"title":"Research fan-out"}]} + +// NOT a plain Node script: launch via the Workflow tool — Workflow({ scriptPath: "/orchestration/research.workflow.mjs" }). +// Emitted by \`construct orchestrate\` from the CURRENT run state. The run is the source +// of truth: if its worklist changes, re-run \`orchestrate --phase research\` before launching. + +// Constants for THIS run (injected at emit time; no Date.now/Math.random in this harness). +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 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) { + return 'Read and follow the dispatch contract at ' + AGENTS + '/' + name + '.md VERBATIM.\\n' + + 'Constants: RUN=' + RUN + ' ENGINE=' + ENGINE + ' WORKLIST=' + WORKLIST + '.\\n' + + 'Invoke the engine only by its ABSOLUTE path: node ' + ENGINE + ' — stdout drills and read-only commands only.' + + (extra ? '\\n' + extra : '') +} + +log('construct research: ' + "6" + ' unit(s) across ' + BATCHES.length + ' agent(s)') + +phase("Research fan-out") +const results = await pipeline(BATCHES, (batch, _item, i) => + agent(contract('researcher', 'GAPS (yours only, each with its drill command):\\n- ' + batch.join('\\n- ')), { label: 'research:' + (i + 1), phase: "Research fan-out", agentType: 'general-purpose', schema: SCHEMA })) + +// One-writer rule: this workflow only COLLECTS research fragments (summaries + URLs). +// The main agent folds them in serially with ONE pinned research re-run — a research run +// REBUILDS the dossier from exactly the angles/URLs it is given, so pass every angle — +// then re-measures the gaps: +// node research --out --angles market,oss,tech --url [--docs-url ] +// node analyze --out +return { phase: "research", worklist: WORKLIST, results: results.filter(Boolean) } +" +`; diff --git a/tests/orchestrate.test.ts b/tests/orchestrate.test.ts new file mode 100644 index 0000000..74a7418 --- /dev/null +++ b/tests/orchestrate.test.ts @@ -0,0 +1,436 @@ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Script } from "node:vm"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadBrief } from "../src/brief.js"; +import { BATCH_SIZE, PHASES, SMALL_WORKLIST, listPhases, orchestrateRun } from "../src/orchestrate.js"; +import { toBatches } from "../src/orchestrate-templates.js"; +import { loadPlan, readyFrontier, writePlan } from "../src/plan.js"; +import { renderSRD } from "../src/render.js"; +import { runReview } from "../src/review.js"; +import type { EvidenceItem } from "../src/types.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const FIX = join(ROOT, "tests", "fixtures"); +const BIN = join(ROOT, "scripts", "construct.mjs"); +// The unit-level engine path is a fixed constant so emitted artifacts are +// assertable; the CLI resolves the real one via realpathSync(import.meta.url). +const ENGINE = "/opt/skills/construct/scripts/construct.mjs"; +// Deterministic render timestamp: emission must be byte-stable across runs. +const GENERATED_AT = "2026-07-09T00:00:00.000Z"; + +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +/** + * A run dir built through the REAL engine writers (the same fixtures + code + * paths the pipeline uses): fixtures seed brief+evidence, renderSRD writes + * SRD.json + BUILD-PLAN.json, runReview writes VERIFY.todo.json. + */ +function makeRun(opts: { seed?: boolean; render?: boolean; review?: boolean; frontier?: boolean } = {}): string { + const run = mkdtempSync(join(tmpdir(), "construct-orch-")); + dirs.push(run); + if (!(opts.seed || opts.render || opts.review || opts.frontier)) return run; + mkdirSync(join(run, "evidence"), { recursive: true }); + cpSync(join(FIX, "sample-brief.json"), join(run, "brief.json")); + cpSync(join(FIX, "sample-evidence.json"), join(run, "evidence", "evidence.json")); + if (opts.render || opts.review || opts.frontier) { + const evidence = JSON.parse(readFileSync(join(run, "evidence", "evidence.json"), "utf8")) as EvidenceItem[]; + renderSRD(loadBrief(run), evidence, { level: "complex", out: run, merge: false, generatedAt: GENERATED_AT }); + } + if (opts.review) runReview(run); + if (opts.frontier) { + // T-000 done → the first real frontier (the M1 tasks + design foundation). + const plan = loadPlan(run)!; + plan.tasks[0]!.status = "done"; + writePlan(run, plan); + } + return run; +} + +const wf = (run: string, phase: string) => join(run, "orchestration", `${phase}.workflow.mjs`); +const readWf = (run: string, phase: string) => readFileSync(wf(run, phase), "utf8"); +const stable = (src: string, run: string) => src.replaceAll(run, "").replaceAll(ENGINE, ""); +const batchesOf = (src: string): string[][] => { + const m = src.match(/const BATCHES = (\[.*?\])\n/s); + expect(m, "const BATCHES missing").not.toBeNull(); + return JSON.parse(m![1]!) as string[][]; +}; + +describe("orchestrate — listPhases", () => { + it("reports all four phases not ready on an empty run, naming the producing command", () => { + const run = makeRun(); + const phases = listPhases(run, ENGINE); + expect(phases.map((p) => p.name)).toEqual(["research", "claim-review", "adr-judges", "build"]); + for (const p of phases) { + expect(p.ready).toBe(false); + expect(p.items).toBe(0); + expect(isAbsolute(p.worklist)).toBe(true); + expect(p.prerequisite).toContain(`node ${ENGINE}`); + } + expect(phases[0]!.prerequisite).toContain("research --out"); + expect(phases[1]!.prerequisite).toContain("review --out"); + expect(phases[2]!.prerequisite).toContain("render --out"); + expect(phases[3]!.prerequisite).toContain("render --out"); + }); + + it("reports ready phases with real item counts and absolute worklist paths", () => { + const run = makeRun({ review: true, frontier: true }); + const phases = listPhases(run, ENGINE); + // The fixtures leave 6 analyze gaps, 6 claim↔evidence pairs, 3 ADRs. + expect(phases[0]).toMatchObject({ name: "research", ready: true, items: 6 }); + expect(phases[1]).toMatchObject({ name: "claim-review", ready: true, items: 6 }); + expect(phases[2]).toMatchObject({ name: "adr-judges", ready: true, items: 3 }); + expect(phases[2]!.ids).toEqual(["0001", "0002", "0003"]); + const frontier = readyFrontier(loadPlan(run)!).frontier; + expect(frontier.length).toBeGreaterThan(1); + expect(phases[3]).toMatchObject({ name: "build", ready: true, items: frontier.length }); + expect(phases[3]!.ids).toEqual(frontier); + for (const p of phases) expect(isAbsolute(p.worklist)).toBe(true); + }); + + it("research units carry the gap AND its drill command rewritten to the absolute engine", () => { + const run = makeRun({ seed: true }); + const research = listPhases(run, ENGINE)[0]!; + expect(research.ready).toBe(true); + expect(research.items).toBe(6); + const tech = research.ids.find((g) => g.includes("PostgreSQL")); + expect(tech).toBeDefined(); + expect(tech).toContain('tech: "PostgreSQL" has no docs/StackOverflow grounding'); + expect(tech).toContain(`node ${ENGINE} tech --out ${run}`); + }); +}); + +describe("orchestrate — emitted workflow", () => { + it("emits one workflow per ready fan-out phase, plus all contracts and the runbook", () => { + const run = makeRun({ review: true, frontier: true }); + const res = orchestrateRun(run, ENGINE); + expect(res.exitCode).toBe(0); + for (const phase of ["research", "claim-review", "build"]) expect(existsSync(wf(run, phase)), phase).toBe(true); + // The judge panel is opt-in (one contested ADR at a time): default emission + // skips it with a notice naming the flag. + expect(existsSync(wf(run, "adr-judges"))).toBe(false); + expect(res.notices.some((n) => n.includes("adr-judges") && n.includes("--adr"))).toBe(true); + expect(existsSync(join(run, "orchestration", "RUNBOOK.md"))).toBe(true); + for (const role of ["researcher", "claim-reviewer", "adr-judge", "builder"]) { + expect(existsSync(join(run, "orchestration", "agents", `${role}.md`)), role).toBe(true); + } + }); + + it("parses as JavaScript the way the Workflow harness evaluates it (meta export + async body)", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + for (const phase of ["research", "claim-review", "build", "adr-judges"]) { + const [metaLine, ...body] = readWf(run, phase).split("\n"); + expect(() => new Script(metaLine!.replace("export const meta =", "const meta ="))).not.toThrow(); + expect(() => new Script(`(async () => {\n${body.join("\n")}\n})`)).not.toThrow(); + } + }); + + it("meta is a pure JSON literal on line 1 (name, description, phases)", () => { + const run = makeRun({ review: true }); + orchestrateRun(run, ENGINE, { phase: "claim-review" }); + const first = readWf(run, "claim-review").split("\n")[0]!; + expect(first.startsWith("export const meta = ")).toBe(true); + const meta = JSON.parse(first.replace("export const meta = ", "")) as { name: string; description: string; phases: unknown[] }; + expect(meta.name).toBe("construct-claim-review"); + expect(meta.description.length).toBeGreaterThan(0); + expect(Array.isArray(meta.phases)).toBe(true); + }); + + it("never contains Date.now / Math.random / new Date (forbidden under the Workflow tool)", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + for (const phase of ["research", "claim-review", "build", "adr-judges"]) { + const src = readWf(run, phase); + expect(src).not.toContain("Date.now("); + expect(src).not.toContain("Math.random("); + expect(src).not.toContain("new Date("); + } + }); + + it("injects absolute RUN/ENGINE/WORKLIST constants matching the run", () => { + const run = makeRun({ review: true }); + orchestrateRun(run, ENGINE); + const src = readWf(run, "claim-review"); + for (const name of ["RUN", "ENGINE", "WORKLIST"]) { + const m = src.match(new RegExp(`const ${name} = "([^"]+)"`)); + expect(m, `const ${name} missing`).not.toBeNull(); + expect(isAbsolute(m![1]!)).toBe(true); + } + expect(src).toContain(JSON.stringify(join(run, "VERIFY.todo.json"))); + expect(src).toContain(JSON.stringify(ENGINE)); + }); + + it("injects the REAL current worklist ids — a doctored worklist shows up on re-emit", () => { + const run = makeRun({ review: true }); + orchestrateRun(run, ENGINE); + expect(readWf(run, "claim-review")).not.toContain("FR-999::E9"); + const todoPath = join(run, "VERIFY.todo.json"); + const todo = JSON.parse(readFileSync(todoPath, "utf8")) as { pairs: Record[] }; + todo.pairs.push({ ...todo.pairs[0]!, claimId: "FR-999", evidenceId: "E9" }); + writeFileSync(todoPath, JSON.stringify(todo, null, 2)); + orchestrateRun(run, ENGINE); + expect(readWf(run, "claim-review")).toContain("FR-999::E9"); + }); + + it("is deterministic — two runs over the same state emit byte-identical artifacts", () => { + const run = makeRun({ review: true, frontier: true }); + const emit = () => { + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + return ( + ["research", "claim-review", "build", "adr-judges"].map((p) => readWf(run, p)).join("\0") + + readFileSync(join(run, "orchestration", "RUNBOOK.md"), "utf8") + + readdirSync(join(run, "orchestration", "agents")) + .map((f) => readFileSync(join(run, "orchestration", "agents", f), "utf8")) + .join("\0") + ); + }; + expect(emit()).toBe(emit()); + }); + + it("batches large worklists 8-per-agent and dispatches one agent per batch", () => { + const run = makeRun({ review: true }); + // Grow the engine-written worklist to 20 pairs (a big SRD), then re-emit. + const todoPath = join(run, "VERIFY.todo.json"); + const todo = JSON.parse(readFileSync(todoPath, "utf8")) as { pairs: Record[] }; + while (todo.pairs.length < 20) todo.pairs.push({ ...todo.pairs[0]!, claimId: `FR-${100 + todo.pairs.length}` }); + writeFileSync(todoPath, JSON.stringify(todo, null, 2)); + orchestrateRun(run, ENGINE, { phase: "claim-review" }); + const src = readWf(run, "claim-review"); + const batches = batchesOf(src); + expect(batches.length).toBe(Math.ceil(20 / BATCH_SIZE)); + expect(batches.flat().length).toBe(20); + expect(src).toContain("pipeline(BATCHES"); + expect(src).toContain("agentType: 'general-purpose'"); + expect(src).toContain("schema: SCHEMA"); + // The chunker itself is order-preserving and exact. + expect(toBatches(["a", "b", "c"], 2)).toEqual([["a", "b"], ["c"]]); + }); + + it("build fans out ONE builder per frontier task, each in its own git worktree", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE, { phase: "build" }); + const src = readWf(run, "build"); + const frontier = readyFrontier(loadPlan(run)!).frontier; + const batches = batchesOf(src); + expect(batches.length).toBe(frontier.length); // batch size 1: one agent per task + expect(batches.flat()).toEqual(frontier); + expect(src).toContain("isolation: 'worktree'"); + // Only builders get a worktree — the read-only fan-outs must not. + orchestrateRun(run, ENGINE); + for (const phase of ["research", "claim-review"]) expect(readWf(run, phase)).not.toContain("isolation:"); + }); + + it("adr-judges emits exactly the 3 lens agents with the ADR + cited evidence pasted in", () => { + const run = makeRun({ render: true }); + const res = orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + expect(res.exitCode).toBe(0); + const src = readWf(run, "adr-judges"); + const batches = batchesOf(src); + expect(batches).toEqual([["feasibility"], ["operations-cost"], ["user-value"]]); + expect(src).toContain("const ADR = "); + expect(src).toContain("const EVIDENCE = "); + expect(src).toContain('"0003"'); + expect(src).not.toContain("isolation:"); + }); + + it("small worklist (≤ SMALL_WORKLIST) → single batch + an eco notice", () => { + const run = makeRun({ render: true }); // frontier is [T-000] only → 1 build unit + const res = orchestrateRun(run, ENGINE, { phase: "build" }); + expect(batchesOf(readWf(run, "build")).flat()).toEqual(["T-000"]); + expect(res.notices.some((n) => n.includes("--eco"))).toBe(true); + expect(SMALL_WORKLIST).toBeLessThan(BATCH_SIZE); + }); + + it("an empty worklist is skipped with a notice, not emitted", () => { + const run = makeRun({ review: true }); + const plan = loadPlan(run)!; + for (const t of plan.tasks) t.status = "done"; + writePlan(run, plan); + const res = orchestrateRun(run, ENGINE); + expect(res.exitCode).toBe(0); + expect(existsSync(wf(run, "build"))).toBe(false); + expect(existsSync(wf(run, "claim-review"))).toBe(true); + expect(res.notices.some((n) => n.includes("build") && n.includes("empty"))).toBe(true); + }); + + it("every contract('') referenced by a workflow has its agents/.md", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + const agents = readdirSync(join(run, "orchestration", "agents")).map((f) => f.replace(/\.md$/, "")); + for (const phase of ["research", "claim-review", "build", "adr-judges"]) { + const refs = [...readWf(run, phase).matchAll(/contract\('([a-z-]+)'/g)].map((m) => m[1]!); + expect(refs.length, phase).toBeGreaterThan(0); + for (const r of refs) expect(agents).toContain(r); + } + }); + + it("workflows return fragments and never contain a write step (the fold stays with the orchestrator)", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + for (const phase of ["research", "claim-review", "build", "adr-judges"]) { + const src = readWf(run, phase); + expect(src).toMatch(/^return \{/m); + expect(src).toContain("results: results.filter(Boolean)"); + // Run-folder writes (--apply, research/render re-runs) may appear only in + // comments — the orchestrator's next step — never as executed code. + const code = src + .split("\n") + .filter((l) => !l.trim().startsWith("//")) + .join("\n"); + expect(code).not.toContain("--apply"); + expect(code).not.toMatch(/\b(research|render|review|init) --out/); + } + }); +}); + +describe("orchestrate — contracts & runbook", () => { + it("every emitted contract carries the one-writer footer and returns structured output", () => { + const run = makeRun({ review: true }); + orchestrateRun(run, ENGINE); + const dir = join(run, "orchestration", "agents"); + const files = readdirSync(dir); + expect(files.sort()).toEqual(["adr-judge.md", "builder.md", "claim-reviewer.md", "researcher.md"]); + for (const f of files) { + const md = readFileSync(join(dir, f), "utf8"); + expect(md).toContain("Return, don't write"); + expect(md).toContain("The orchestrator is the sole writer"); + expect(md).toContain("orchestration/out/"); + expect(md).toContain("structured output"); + } + }); + + it("contracts encode each pattern's judgment rules", () => { + const run = makeRun({ review: true }); + orchestrateRun(run, ENGINE); + const read = (role: string) => readFileSync(join(run, "orchestration", "agents", `${role}.md`), "utf8"); + const researcher = read("researcher"); + expect(researcher).toContain("≤5-line summary"); + expect(researcher).toContain("URLs worth grounding"); + expect(researcher).toContain(loadBrief(run).idea); // the brief one-liner is pasted in + const reviewer = read("claim-reviewer"); + for (const v of ["supported", "partial", "refuted", "unsupported"]) expect(reviewer).toContain(v); + expect(reviewer).toMatch(/HARSHER/i); + expect(reviewer).toContain("claimId::evidenceId"); + const judge = read("adr-judge"); + for (const lens of ["feasibility", "operations-cost", "user-value"]) expect(judge).toContain(lens); + expect(judge).toMatch(/majority/i); + const builder = read("builder"); + expect(builder).toContain("worktree"); + expect(builder).toMatch(/FR id/); + expect(builder).toContain("BUILD-PLAN.json"); + }); + + it("the runbook covers every phase with concrete paths and the phase status", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + const rb = readFileSync(join(run, "orchestration", "RUNBOOK.md"), "utf8"); + expect(rb).toContain(join(run, "VERIFY.todo.json")); + expect(rb).toContain(join(run, "BUILD-PLAN.json")); + expect(rb).toContain(ENGINE); + for (const role of ["researcher.md", "claim-reviewer.md", "adr-judge.md", "builder.md"]) expect(rb).toContain(role); + for (const name of PHASES) expect(rb).toContain(name); + expect(rb).toContain("check --out"); // the gate is named + }); + + it("golden shape (paths normalized)", () => { + const run = makeRun({ review: true, frontier: true }); + orchestrateRun(run, ENGINE); + orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "0003" }); + expect(stable(readWf(run, "research"), run)).toMatchSnapshot("research.workflow.mjs"); + expect(stable(readWf(run, "adr-judges"), run)).toMatchSnapshot("adr-judges.workflow.mjs"); + expect(stable(readFileSync(join(run, "orchestration", "agents", "claim-reviewer.md"), "utf8"), run)).toMatchSnapshot("claim-reviewer.md"); + expect(stable(readFileSync(join(run, "orchestration", "RUNBOOK.md"), "utf8"), run)).toMatchSnapshot("RUNBOOK.md"); + }); +}); + +describe("orchestrate — eco mode & phase gating", () => { + it("--eco emits RUNBOOK + contracts only, no workflow scripts", () => { + const run = makeRun({ review: true, frontier: true }); + const res = orchestrateRun(run, ENGINE, { eco: true }); + expect(res.exitCode).toBe(0); + expect(existsSync(join(run, "orchestration", "RUNBOOK.md"))).toBe(true); + expect(existsSync(join(run, "orchestration", "agents", "claim-reviewer.md"))).toBe(true); + for (const phase of PHASES) expect(existsSync(wf(run, phase))).toBe(false); + }); + + it("--phase on a not-ready phase exits 2 and names the producing command", () => { + const run = makeRun({ seed: true }); // no SRD yet + const res = orchestrateRun(run, ENGINE, { phase: "claim-review" }); + expect(res.exitCode).toBe(2); + expect(res.errors.some((e) => e.includes("review --out"))).toBe(true); + expect(existsSync(wf(run, "claim-review"))).toBe(false); + }); + + it("--phase restricts emission to that phase", () => { + const run = makeRun({ review: true, frontier: true }); + const res = orchestrateRun(run, ENGINE, { phase: "claim-review" }); + expect(res.exitCode).toBe(0); + expect(existsSync(wf(run, "claim-review"))).toBe(true); + expect(existsSync(wf(run, "research"))).toBe(false); + expect(existsSync(wf(run, "build"))).toBe(false); + }); + + it("an unknown phase exits 2 naming the valid ones", () => { + const run = makeRun({ seed: true }); + const res = orchestrateRun(run, ENGINE, { phase: "nope" }); + expect(res.exitCode).toBe(2); + expect(res.errors.some((e) => PHASES.every((p) => e.includes(p)))).toBe(true); + }); + + it("adr-judges without --adr exits 2 naming the run's ADR ids", () => { + const run = makeRun({ render: true }); + const res = orchestrateRun(run, ENGINE, { phase: "adr-judges" }); + expect(res.exitCode).toBe(2); + expect(res.errors.some((e) => e.includes("--adr") && e.includes("0001") && e.includes("0003"))).toBe(true); + }); + + it("adr-judges with an unknown --adr exits 2 naming the available ids", () => { + const run = makeRun({ render: true }); + const res = orchestrateRun(run, ENGINE, { phase: "adr-judges", adr: "9999" }); + expect(res.exitCode).toBe(2); + expect(res.errors.some((e) => e.includes("9999") && e.includes("0001"))).toBe(true); + }); +}); + +describe("orchestrate — CLI wiring (the shipped bundle)", () => { + function cli(args: string[]): { status: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, [BIN, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return { status: 0, stdout, stderr: "" }; + } catch (e) { + const err = e as { status?: number; stdout?: string | Buffer; stderr?: string | Buffer }; + return { status: err.status ?? 1, stdout: String(err.stdout ?? ""), stderr: String(err.stderr ?? "") }; + } + } + + it("orchestrate without --out exits 2", () => { + expect(cli(["orchestrate"]).status).toBe(2); + }); + + it("orchestrate --out --list exits 0 with a {phases:[...]} JSON; a full run emits and exits 0", () => { + const run = makeRun({ review: true }); + const list = cli(["orchestrate", "--out", run, "--list"]); + expect(list.status).toBe(0); + const parsed = JSON.parse(list.stdout) as { phases: { name: string; ready: boolean }[] }; + expect(parsed.phases.map((p) => p.name)).toEqual([...PHASES]); + const emit = cli(["orchestrate", "--out", run]); + expect(emit.status, emit.stderr).toBe(0); + expect(existsSync(wf(run, "claim-review"))).toBe(true); + }); + + it("orchestrate --out exits 2", () => { + expect(cli(["orchestrate", "--out", join(tmpdir(), "construct-does-not-exist-xyz")]).status).toBe(2); + }); +});