canon-ai 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/orchestrator/signals.ts
4
+ import { setTimeout as delay } from "timers/promises";
4
5
  var activeChildren = /* @__PURE__ */ new Set();
6
+ var shuttingDown = false;
7
+ function isShuttingDown() {
8
+ return shuttingDown;
9
+ }
5
10
  var shutdownHooks = [];
6
11
  function registerShutdownHook(hook) {
7
12
  shutdownHooks.push(hook);
@@ -31,10 +36,28 @@ function killChildGroup(child, sig) {
31
36
  process.on("SIGHUP", () => {
32
37
  process.stderr.write("WARN: SIGHUP received; ignoring (orchestrator survives supervising-shell exit).\n");
33
38
  });
34
- function forwardAndExit(sig) {
35
- for (const child of activeChildren) {
39
+ function childGroupAlive(child) {
40
+ if (child.pid == null) return false;
41
+ try {
42
+ process.kill(-child.pid, 0);
43
+ return true;
44
+ } catch (error) {
45
+ if (error.code === "EPERM") return true;
46
+ return child.exitCode === null && child.signalCode === null;
47
+ }
48
+ }
49
+ async function forwardAndExit(sig) {
50
+ if (shuttingDown) return;
51
+ shuttingDown = true;
52
+ const children = [...activeChildren];
53
+ for (const child of children) {
36
54
  killChildGroup(child, sig);
37
55
  }
56
+ const graceDeadline = Date.now() + 3e3;
57
+ while (children.some(childGroupAlive) && Date.now() < graceDeadline) await delay(50);
58
+ for (const child of children.filter(childGroupAlive)) killChildGroup(child, "SIGKILL");
59
+ const reapDeadline = Date.now() + 1e3;
60
+ while (children.some(childGroupAlive) && Date.now() < reapDeadline) await delay(50);
38
61
  for (const hook of shutdownHooks) {
39
62
  try {
40
63
  hook(sig);
@@ -44,21 +67,25 @@ function forwardAndExit(sig) {
44
67
  process.removeAllListeners(sig);
45
68
  process.kill(process.pid, sig);
46
69
  }
47
- process.on("SIGINT", () => forwardAndExit("SIGINT"));
48
- process.on("SIGTERM", () => forwardAndExit("SIGTERM"));
70
+ process.on("SIGINT", () => {
71
+ void forwardAndExit("SIGINT");
72
+ });
73
+ process.on("SIGTERM", () => {
74
+ void forwardAndExit("SIGTERM");
75
+ });
49
76
 
50
77
  // src/orchestrator/run-task.ts
51
78
  import { pathToFileURL } from "url";
52
79
 
53
80
  // src/orchestrator/main.ts
54
81
  import { spawnSync as spawnSync6 } from "child_process";
55
- import fs19 from "fs";
82
+ import fs20 from "fs";
56
83
  import os from "os";
57
- import path19 from "path";
84
+ import path20 from "path";
58
85
 
59
86
  // src/orchestrator/phases/code-review.ts
60
- import fs14 from "fs";
61
- import path14 from "path";
87
+ import fs15 from "fs";
88
+ import path15 from "path";
62
89
 
63
90
  // src/orchestrator/cli.ts
64
91
  import fs from "fs";
@@ -1883,6 +1910,7 @@ function streamProcess(command, args, options) {
1883
1910
  child.on("error", (err) => {
1884
1911
  if (stallTimer) clearTimeout(stallTimer);
1885
1912
  if (killTimer) clearTimeout(killTimer);
1913
+ if (isShuttingDown()) return;
1886
1914
  resolve({
1887
1915
  exitCode: null,
1888
1916
  signal: null,
@@ -1896,6 +1924,7 @@ function streamProcess(command, args, options) {
1896
1924
  closed = true;
1897
1925
  if (stallTimer) clearTimeout(stallTimer);
1898
1926
  if (killTimer) clearTimeout(killTimer);
1927
+ if (isShuttingDown()) return;
1899
1928
  resolve({
1900
1929
  exitCode: code,
1901
1930
  signal,
@@ -3733,8 +3762,8 @@ function buildSharedDocAbortMessage(abortedFiles) {
3733
3762
  }
3734
3763
 
3735
3764
  // src/orchestrator/prompts/index.ts
3736
- import fs10 from "fs";
3737
- import path10 from "path";
3765
+ import fs11 from "fs";
3766
+ import path11 from "path";
3738
3767
 
3739
3768
  // src/orchestrator/context.ts
3740
3769
  import fs8 from "fs";
@@ -3963,13 +3992,53 @@ ${acSection}`;
3963
3992
  }
3964
3993
 
3965
3994
  // src/orchestrator/review-archive.ts
3995
+ import fs10 from "fs";
3996
+ import path10 from "path";
3997
+
3998
+ // src/task/templates.ts
3966
3999
  import fs9 from "fs";
3967
4000
  import path9 from "path";
4001
+ function tasksRoot() {
4002
+ return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
4003
+ }
4004
+ function templatesRoot() {
4005
+ return path9.join(process.cwd(), ".canon", "templates");
4006
+ }
4007
+ function taskTemplateOverrideRoot() {
4008
+ return path9.join(tasksRoot(), "_templates");
4009
+ }
4010
+ function resolveTaskTemplateSource(basename) {
4011
+ const override = path9.join(taskTemplateOverrideRoot(), basename);
4012
+ if (fs9.existsSync(override)) return override;
4013
+ const managed = path9.join(templatesRoot(), basename);
4014
+ return fs9.existsSync(managed) ? managed : null;
4015
+ }
4016
+ function renderTaskTemplate(source, taskId, title) {
4017
+ return fs9.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
4018
+ }
4019
+ function scaffoldTaskArtifact(taskDir, basename, taskId, title) {
4020
+ const source = resolveTaskTemplateSource(basename);
4021
+ if (!source) return null;
4022
+ fs9.writeFileSync(path9.join(taskDir, basename), renderTaskTemplate(source, taskId, title), "utf8");
4023
+ return source;
4024
+ }
4025
+ function isPristineTaskArtifact(content, basename, taskId, title) {
4026
+ if (content.includes("[TASK-ID]")) return true;
4027
+ const source = resolveTaskTemplateSource(basename);
4028
+ if (!source) return false;
4029
+ try {
4030
+ return content === renderTaskTemplate(source, taskId, title);
4031
+ } catch {
4032
+ return false;
4033
+ }
4034
+ }
4035
+
4036
+ // src/orchestrator/review-archive.ts
3968
4037
  var REVIEW_ARCHIVE_PREFIX = "review-prior-";
3969
4038
  var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
3970
4039
  function newestReviewArchiveNumber(taskDir) {
3971
4040
  let newest = 0;
3972
- for (const name of fs9.readdirSync(taskDir)) {
4041
+ for (const name of fs10.readdirSync(taskDir)) {
3973
4042
  const match = REVIEW_ARCHIVE_RE.exec(name);
3974
4043
  if (match) newest = Math.max(newest, Number(match[1]));
3975
4044
  }
@@ -3980,16 +4049,28 @@ function findNewestReviewArchive(taskDir) {
3980
4049
  return newest === 0 ? null : `${REVIEW_ARCHIVE_PREFIX}${newest}.md`;
3981
4050
  }
3982
4051
  function archivePriorReview(taskDir, options = {}) {
3983
- const reviewPath = path9.join(taskDir, "review.md");
3984
- if (!fs9.existsSync(reviewPath)) return null;
4052
+ const reviewPath = path10.join(taskDir, "review.md");
4053
+ if (!fs10.existsSync(reviewPath)) return null;
3985
4054
  if (options.skipUnfilledTemplate) {
3986
- const content = fs9.readFileSync(reviewPath, "utf8");
4055
+ const content = fs10.readFileSync(reviewPath, "utf8");
3987
4056
  if (isTemplateUnfilled(content)) return null;
4057
+ if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
4058
+ return null;
4059
+ }
3988
4060
  }
3989
4061
  const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
3990
- fs9.renameSync(reviewPath, path9.join(taskDir, archiveName));
4062
+ fs10.renameSync(reviewPath, path10.join(taskDir, archiveName));
3991
4063
  return archiveName;
3992
4064
  }
4065
+ function rescaffoldReview(taskDir, identity) {
4066
+ try {
4067
+ if (fs10.existsSync(path10.join(taskDir, "review.md"))) return { outcome: "exists" };
4068
+ const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
4069
+ return source ? { outcome: "written", source } : { outcome: "no-template" };
4070
+ } catch (error) {
4071
+ return { outcome: "error", message: error instanceof Error ? error.message : String(error) };
4072
+ }
4073
+ }
3993
4074
 
3994
4075
  // node_modules/mustache/mustache.mjs
3995
4076
  var objectToString = Object.prototype.toString;
@@ -4457,7 +4538,7 @@ function renderTemplate(template, view) {
4457
4538
  }
4458
4539
 
4459
4540
  // src/orchestrator/prompts/templates/code-review-foreman.md
4460
- var code_review_foreman_default = "You are the synthesis foreman for the code review phase for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\n## Code-Review Rules of Thumb (Foreman)\n\n- **Reviewer diffs against the task baseline, not `main`, on release branches**: on a shared release branch ahead of `main`, always diff against the task's baseline \u2014 diffing against `main` attributes unrelated work to the task.\n- **Use `git -C <absolute-path>` for every worktree git op, not `cd` + git**: when operating across REPO_ROOT and a task worktree, `git -C /absolute/path` avoids silent cwd reversion between tool calls.\n- **Don't infer one git invariant from another**: `git status --porcelain` empty \u2260 origin matches HEAD; `origin/<branch>` exists \u2260 origin matches HEAD; PR exists \u2260 PR is in the expected state. Do the actual check directly.\n- **A cross-cutting invariant belongs in one shared helper, not patched per call site**: when the same rule must hold at multiple enforcement points, implement it once. The tell: findings come back round after round as the same bug class at a new location. At \u22653 sites, extract the shared helper and route all sites through it.\n\nYour job is to synthesize three review inputs: the anchored Claude lens, the cold-Claude lens, and the pre-obtained cold-Codex findings injected below. You spawn the Claude lenses as isolated sub-agents, collect their findings, adjudicate all three inputs using the spec (which you hold and the cold lenses do not), then write one `review.md` and set the verdict. Do not run `codex` yourself.\n\nTasks:\n{{{taskLines}}}\n\n{{#isRound1}}\nThis is Round 1, the initial code review.\n{{/isRound1}}\n{{^isRound1}}\nThis is Round {{roundN}}: re-review after iteration {{priorIteration}}. The lenses re-run from scratch. Direct the anchored lens to read the Iteration {{priorIteration}} section of `handoff.md` that addresses review round {{priorIteration}}.\n{{#tightenLine}}\n{{{tightenLine}}}\n{{/tightenLine}}\n{{/isRound1}}\n\n{{#hasDiff}}\nTask diff against {{{baseBranch}}}:\n\n```diff\n{{{diffContent}}}\n```\n{{#diffTruncated}}\n> Diff truncated at 50 000 bytes. Give the Claude lenses the visible diff first; for the omitted remainder, direct them to inspect only the changed files named in the handoff Changes table. Do not give the cold-Claude lens spec, AC, or canon-doc context.\n{{/diffTruncated}}\n{{/hasDiff}}\n{{^hasDiff}}\nRetrieve the task diff with `git diff {{{baseBranch}}}...HEAD`.\n{{/hasDiff}}\n\n## Injected Cold-Codex Findings\n\n{{#hasColdCodexFindings}}\nThe orchestrator ran `codex review` over the task's branch diff before spawning you. Its findings are reproduced below. These are unanchored: Codex reviewed adversarially without the spec as a checklist. Treat them as the third lens input. Do not re-run Codex; synthesize these findings alongside the Claude lens outputs.\n\n{{{coldCodexFindings}}}\n{{/hasColdCodexFindings}}\n{{^hasColdCodexFindings}}\nNo cold-Codex findings were provided to this prompt. In production code_review, the orchestrator must obtain that artifact before foreman synthesis; do not treat a missing cold-Codex lens as approval evidence.\n{{/hasColdCodexFindings}}\n\n## Foreman Protocol\n\n### 1. Spawn Claude Lenses In Parallel\n\nUse the Task tool to spawn the Claude lenses simultaneously:\n\n**Anchored lens** (`subagent_type: code-review-anchored`)\n- Give it the full diff, `spec.md`, `handoff.md`, and prior `review.md` if this is a re-review.\n- It applies canon's anchored Stage 1 / Stage 2 code-review charter.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\n**Cold-Claude lens** (`subagent_type: code-review-cold`)\n- Give it the full diff and base ref only.\n- Do not give it `spec.md`, ACs, handoff rationale, canon docs, known risks, or your anchored-lens prompt.\n- If it needs to inspect files for truncated diff context, constrain it to changed files only and preserve the spec-blind framing.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\nThe injected cold-Codex findings above are the third lens input. Do not spawn a Codex agent or shell out to Codex yourself. Do not let a Claude lens see another lens's output.\n\n### 2. Adjudicate\n\nUse the three lens inputs and the spec. Do not perform a new full diff review for novel bugs; your role is synthesis and adjudication.\n\nThe lenses are instructed to over-report \u2014 to surface low-confidence and low-severity findings rather than self-censor. Filtering is **your** job, not theirs: a quiet lens output is a bug in the lens, not a clean diff. Rank surviving findings by confidence \xD7 severity. A low-confidence, low-severity finding is a nit or gets dismissed; it does not by itself drive `changes_requested`. Do not discard a finding merely because a lens marked it low-confidence \u2014 verify it against the spec/diff first, then rank.\n\n1. Dedup: if 2+ lenses flagged the same behavior, collapse it to one finding and record \"flagged by N lenses.\" A finding flagged by 2+ lenses is higher-confidence regardless of any lens's self-tag. Cross-model agreement \u2014 the same behavior flagged by cold-Claude and cold-Codex \u2014 must not be dismissed as spec-intended without explicit spec evidence cited in `review.md`.\n2. Keep the two reconciliation checks separate:\n - Does it hold against the code? For cold findings (cold-Claude and cold-Codex), verify each against the diff/code. Codex P-levels are claims to check, not verdicts. A finding that does not hold gets recorded as `Dismissed (cold-Claude): <finding> - <reason>` or `Dismissed (cold-Codex): <finding> - <reason>`.\n - Is it in spec scope? Apply this only to anchored-lens findings as part of the Stage 1 / Stage 2 charter.\n - Forbidden: do not dismiss a verified cold-Claude or cold-Codex finding merely for being off-AC or out of spec scope. A real bug caught by a cold lens is still a bug even if no AC named it.\n3. Altitude classification: every surviving finding is either:\n - `code-bug`: the implementation is wrong or test integrity is compromised.\n - `spec-gap`: the implementation may match the written spec, but the spec is missing, wrong, or too ambiguous for the implementer to fix.\n\n### 3. Choose Verdict\n\n- Any `code-bug` finding -> `changes_requested`.\n- Any `spec-gap` finding and no code-bugs -> `spec_gap`.\n- Optional nits or cleanup without blocking findings -> `approved_with_nits`.\n- No surviving findings -> `approved`.\n\nTest-integrity findings are always code-bugs.\n\n### 4. Write `review.md`\n\nFor each task, write `tasks/<id>/review.md`.\n\nRound 1 fills the existing template structure directly \u2014 do **not** wrap it in a `## Round 1` section; the `## Stage 1` and `## Stage 2` headings stay at H2. Re-review appends a new `## Round {{roundN}}` section near the bottom (with `### Stage 1` / `### Stage 2` sub-headings), preserving earlier rounds.\n\nInclude:\n- Stage 1: anchored lens validation gate result and AC table.\n- Stage 2 / Findings: surviving findings with altitude (`code-bug` or `spec-gap`), source lens, and file:line.\n- Dismissed Cold Findings: every dropped cold finding plus the reason, including `Dismissed (cold-Claude): ...` and `Dismissed (cold-Codex): ...` entries where applicable.\n- Final Verdict: check exactly one verdict checkbox, including `Spec gap` when applicable.\n\n### 5. Set Phase Verdict\n\nRun one command per task with the actual verdict:\n{{{phaseCommands}}}\n";
4541
+ var code_review_foreman_default = "You are the synthesis foreman for the code review phase for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\n## Code-Review Rules of Thumb (Foreman)\n\n- **Reviewer diffs against the task baseline, not `main`, on release branches**: on a shared release branch ahead of `main`, always diff against the task's baseline \u2014 diffing against `main` attributes unrelated work to the task.\n- **Use `git -C <absolute-path>` for every worktree git op, not `cd` + git**: when operating across REPO_ROOT and a task worktree, `git -C /absolute/path` avoids silent cwd reversion between tool calls.\n- **Don't infer one git invariant from another**: `git status --porcelain` empty \u2260 origin matches HEAD; `origin/<branch>` exists \u2260 origin matches HEAD; PR exists \u2260 PR is in the expected state. Do the actual check directly.\n- **A cross-cutting invariant belongs in one shared helper, not patched per call site**: when the same rule must hold at multiple enforcement points, implement it once. The tell: findings come back round after round as the same bug class at a new location. At \u22653 sites, extract the shared helper and route all sites through it.\n\nYour job is to synthesize three review inputs: the anchored Claude lens, the cold-Claude lens, and the pre-obtained cold-Codex findings injected below. You spawn the Claude lenses as isolated sub-agents, collect their findings, adjudicate all three inputs using the spec (which you hold and the cold lenses do not), then write one `review.md` and set the verdict. Do not run `codex` yourself.\n\nTasks:\n{{{taskLines}}}\n\n{{#isRound1}}\nThis is Round 1, the initial code review.\n{{/isRound1}}\n{{^isRound1}}\nThis is Round {{roundN}}: re-review after iteration {{priorIteration}}. The lenses re-run from scratch. Direct the anchored lens to read the Iteration {{priorIteration}} section of `handoff.md` that addresses review round {{priorIteration}}.\n{{#tightenLine}}\n{{{tightenLine}}}\n{{/tightenLine}}\n{{/isRound1}}\n\n{{#hasDiff}}\nTask diff against {{{baseBranch}}}:\n\n```diff\n{{{diffContent}}}\n```\n{{#diffTruncated}}\n> Diff truncated at 50 000 bytes. Give the Claude lenses the visible diff first; for the omitted remainder, direct them to inspect only the changed files named in the handoff Changes table. Do not give the cold-Claude lens spec, AC, or canon-doc context.\n{{/diffTruncated}}\n{{/hasDiff}}\n{{^hasDiff}}\nRetrieve the task diff with `git diff {{{baseBranch}}}...HEAD`.\n{{/hasDiff}}\n\n## Injected Cold-Codex Findings\n\n{{#hasColdCodexFindings}}\nThe orchestrator ran `codex review` over the task's branch diff before spawning you. Its findings are reproduced below. These are unanchored: Codex reviewed adversarially without the spec as a checklist. Treat them as the third lens input. Do not re-run Codex; synthesize these findings alongside the Claude lens outputs.\n\n{{{coldCodexFindings}}}\n{{/hasColdCodexFindings}}\n{{^hasColdCodexFindings}}\nNo cold-Codex findings were provided to this prompt. In production code_review, the orchestrator must obtain that artifact before foreman synthesis; do not treat a missing cold-Codex lens as approval evidence.\n{{/hasColdCodexFindings}}\n\n## Foreman Protocol\n\n### 1. Spawn Claude Lenses In Parallel\n\nSpawn both Claude lenses with the sub-agent tool (`Agent`, called `Task` in older harnesses) in a single message so they run concurrently, and run them **in the foreground** (`run_in_background: false`) so the call returns their findings. Do not spawn them in the background and do not end your turn to wait for them: a turn that ends while a lens is still running ends the code review with no `review.md` and no verdict, and the phase is retried from scratch. Your turn ends only after step 5 below has run.\n\n**Anchored lens** (`subagent_type: code-review-anchored`)\n- Give it the full diff, `spec.md`, `handoff.md`, and prior `review.md` if this is a re-review.\n- It applies canon's anchored Stage 1 / Stage 2 code-review charter.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\n**Cold-Claude lens** (`subagent_type: code-review-cold`)\n- Give it the full diff and base ref only.\n- Do not give it `spec.md`, ACs, handoff rationale, canon docs, known risks, or your anchored-lens prompt.\n- If it needs to inspect files for truncated diff context, constrain it to changed files only and preserve the spec-blind framing.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\nThe injected cold-Codex findings above are the third lens input. Do not spawn a Codex agent or shell out to Codex yourself. Do not let a Claude lens see another lens's output.\n\n### 2. Adjudicate\n\nUse the three lens inputs and the spec. Do not perform a new full diff review for novel bugs; your role is synthesis and adjudication.\n\nThe lenses are instructed to over-report \u2014 to surface low-confidence and low-severity findings rather than self-censor. Filtering is **your** job, not theirs: a quiet lens output is a bug in the lens, not a clean diff. Rank surviving findings by confidence \xD7 severity. A low-confidence, low-severity finding is a nit or gets dismissed; it does not by itself drive `changes_requested`. Do not discard a finding merely because a lens marked it low-confidence \u2014 verify it against the spec/diff first, then rank.\n\n1. Dedup: if 2+ lenses flagged the same behavior, collapse it to one finding and record \"flagged by N lenses.\" A finding flagged by 2+ lenses is higher-confidence regardless of any lens's self-tag. Cross-model agreement \u2014 the same behavior flagged by cold-Claude and cold-Codex \u2014 must not be dismissed as spec-intended without explicit spec evidence cited in `review.md`.\n2. Keep the two reconciliation checks separate:\n - Does it hold against the code? For cold findings (cold-Claude and cold-Codex), verify each against the diff/code. Codex P-levels are claims to check, not verdicts. A finding that does not hold gets recorded as `Dismissed (cold-Claude): <finding> - <reason>` or `Dismissed (cold-Codex): <finding> - <reason>`.\n - Is it in spec scope? Apply this only to anchored-lens findings as part of the Stage 1 / Stage 2 charter.\n - Forbidden: do not dismiss a verified cold-Claude or cold-Codex finding merely for being off-AC or out of spec scope. A real bug caught by a cold lens is still a bug even if no AC named it.\n3. Altitude classification: every surviving finding is either:\n - `code-bug`: the implementation is wrong or test integrity is compromised.\n - `spec-gap`: the implementation may match the written spec, but the spec is missing, wrong, or too ambiguous for the implementer to fix.\n\n### 3. Choose Verdict\n\n- Any `code-bug` finding -> `changes_requested`.\n- Any `spec-gap` finding and no code-bugs -> `spec_gap`.\n- Optional nits or cleanup without blocking findings -> `approved_with_nits`.\n- No surviving findings -> `approved`.\n\nTest-integrity findings are always code-bugs.\n\n### 4. Write `review.md`\n\nFor each task, write `tasks/<id>/review.md`.\n\nRound 1 fills the existing template structure directly \u2014 do **not** wrap it in a `## Round 1` section; the `## Stage 1` and `## Stage 2` headings stay at H2. Re-review appends a new `## Round {{roundN}}` section near the bottom (with `### Stage 1` / `### Stage 2` sub-headings), preserving earlier rounds.\n\nInclude:\n- Stage 1: anchored lens validation gate result and AC table.\n- Stage 2 / Findings: surviving findings with altitude (`code-bug` or `spec-gap`), source lens, and file:line.\n- Dismissed Cold Findings: every dropped cold finding plus the reason, including `Dismissed (cold-Claude): ...` and `Dismissed (cold-Codex): ...` entries where applicable.\n- Final Verdict: check exactly one verdict checkbox, including `Spec gap` when applicable.\n\n### 5. Set Phase Verdict\n\nRun one command per task with the actual verdict:\n{{{phaseCommands}}}\n";
4461
4542
 
4462
4543
  // src/orchestrator/prompts/templates/implement.md
4463
4544
  var implement_default = "You are implementing {{taskScope}} for {{projectName}}.\n\n{{{stateHeader}}}\n{{{startup}}}\n{{{risksBlock}}}{{{pitfallsBlock}}}{{{contextBlock}}}\n{{{affectedFilesBlock}}}\nTasks to implement:\n{{{taskLines}}}{{#isBundle}}\nThese tasks are related \u2014 implement them together. Consider shared code paths and cross-task interactions.{{/isBundle}}\n\nGrounding rule: before you write handoff.md, re-open the files you changed and verify the current diff against the spec. Do not treat a previous session's memory as proof that the work is already in place.\n\n**Spec ACs are binding. Plan approach is guidance.**\n- Every Acceptance Criterion in spec.md MUST be met \u2014 these are non-negotiable.\n- If you find a better implementation approach than what's in the plan, use it. Document every deviation in handoff.md under \"Deviations\" with specific rationale.\n- You may NOT silently drop an AC, skip a required validation check, or omit a spec requirement.\n- If an AC is infeasible as written, document it in Blockers \u2014 do not silently skip.\n- If an AC is ambiguous enough that two reasonable implementations exist, document your interpretation in handoff.md under Blockers with label `[ambiguity]` \u2014 do not silently guess. Claude will evaluate whether the interpretation was correct.\n\n## Implementation Rules\n\n**Safe-First Rules** \u2014 always applicable regardless of stack:\n1. For storage, reload, sync, or data-affecting flows: ship the safer guarded behavior first.\n2. Behavior that reloads the app, replaces local state, or dismisses user work must be gated by explicit user action.\n3. Prefer shared types over duplicating signatures.\n\n**Scope Discipline** \u2014 always applicable; the spec is the contract:\n1. **Affected Files is the scope cap.** If satisfying an AC genuinely requires editing files outside the spec's *Affected Files* table, stop, document the gap in `handoff.md` under *Blockers*, and surface it for human attention. Do not silently expand scope.\n2. **No unauthorized new abstractions.** Do not introduce new top-level modules, services, packages, or routing layers that the spec did not authorize. Minor refactors within an authorized file are fine; new abstractions are an architecture decision and belong in the spec.\n3. **No incidental dependency changes.** Do not add, remove, upgrade, or downgrade dependencies (or their pinned versions) unless the spec explicitly requests it.\n\n**Lint & Type Safety Policy** \u2014 always applicable:\n1. **Suppressing a lint or type error is a last resort**, not a convenience escape hatch. Never add a suppression without a same-line justification explaining *why the rule is wrong for this specific case*.\n2. **`any` / dynamic typing**: When the shape is truly unknown at the boundary, type as `unknown` and narrow explicitly.\n\n**Bug/Flake-Fix Red-First Checkpoint** \u2014 applicable when a spec's ACs include a red-first regression test:\nWrite the test and run it against the pre-fix code first; confirm it fails *for the reason the spec states*, then apply the fix and confirm it passes. Report the red run (command + observed failure) in handoff.md. If the test cannot be made to fail on the pre-fix code for the stated reason, stop \u2014 the spec's mechanism is wrong. Document it in handoff.md under Blockers with label `[wrong-premise]` and do not implement a fix on a premise you could not reproduce. If the spec instead uses the environment-bound-and-impractical escape, run its named deterministic alternative before fixing if it is executable in your sandbox (e.g. an integration fixture) and report the outcome in handoff.md the same way; if it is not executable (e.g. a documented manual repro), state that in handoff.md instead \u2014 never report an outcome for a run that did not happen.\n\n**Parsing Structured Input** \u2014 always applicable when implementing a parser for author-facing structured input:\nParse cell-by-cell with explicit rejection, not a permissive whole-string regex. Anchor each cell to exactly one expected shape and reject malformed cells with a specific reason at the parse boundary.\n\nRun ALL applicable validation checks before writing handoff. See \"Validation Required\" in each spec.md. The universal change-type \u2192 check-category matrix:\n\n| Change Type | Required Check Categories |\n|---|---|\n| Most changes | Linting, type checking, unit tests |\n| Docs references | Docs references |\n| Routes / config / build | Full build |\n| UI / interaction changes | End-to-end tests |\n| Content / SEO / metadata | Prerender / sitemap / feed regeneration |\n| Schema / migration | Migration runner + manual review |\n| Cross-platform | Subset of the above on each platform |\n\nFor which command runs each category: see `docs/architecture.md` \xA7Validation (project command bindings). Required checks must be recorded as Pass or Fail; do not mark a required check N/A unless the spec explicitly removed it.\n\n**Test flakiness in your sandbox.** Validation suites \u2014 especially E2E or integration tests \u2014 can hit transient failures (timing races, environment quirks, network jitter) that have nothing to do with the code in your spec's Affected Files. **If a failure is in a test / file outside your Affected Files table, do NOT fix it.** Note the observed test name, file, line, and a one-line repro hint in handoff.md \u2192 Blockers (or \"Validation Outcomes\" Notes column with status `Fail \u2013 unrelated`), then continue. `Fail \u2013 unrelated` is only valid for failures in files outside your Affected Files; a failure in a file you changed is yours to fix. Scope discipline > fixing adjacent bugs you spot during validation. The reviewer/operator will decide whether to triage the unrelated failure separately.\n\nFor each task, write tasks/<id>/handoff.md using the template. The Validation Outcomes table must have no Fail results EXCEPT for unrelated-flake rows clearly labeled in the Notes column.\nAppend to tasks/<id>/notes.md for any surprising codebase behavior (prefix: [implement]).\n\nWhen done, run:\n{{{phaseCommands}}}\n";
@@ -4795,9 +4876,9 @@ function priorReviewReference(taskId) {
4795
4876
  }
4796
4877
  function bundleHasRealPriorReview(taskIds) {
4797
4878
  return taskIds.every((taskId) => {
4798
- const reviewPath = path10.join(taskDirFor(taskId), "review.md");
4879
+ const reviewPath = path11.join(taskDirFor(taskId), "review.md");
4799
4880
  try {
4800
- const content = fs10.readFileSync(reviewPath, "utf8");
4881
+ const content = fs11.readFileSync(reviewPath, "utf8");
4801
4882
  const hasH2 = /^## Stage 1\b/m.test(content);
4802
4883
  const hasNested = /^## Round \d+\b/m.test(content) && /^### Stage 1\b/m.test(content);
4803
4884
  return (hasH2 || hasNested) && !content.includes("[TASK-ID]");
@@ -4868,30 +4949,30 @@ function promptQa(state, prTemplate) {
4868
4949
 
4869
4950
  // src/task/index.ts
4870
4951
  import { spawnSync as spawnSync5 } from "child_process";
4871
- import fs13 from "fs";
4872
- import path13 from "path";
4952
+ import fs14 from "fs";
4953
+ import path14 from "path";
4873
4954
 
4874
4955
  // src/orchestrator/canon-snapshot.ts
4875
4956
  import { spawnSync as spawnSync4 } from "child_process";
4876
- import fs11 from "fs";
4877
- import path11 from "path";
4957
+ import fs12 from "fs";
4958
+ import path12 from "path";
4878
4959
  var CANON_UPSTREAM_REPO = "tstraub89/canon-ai";
4879
4960
  function isInstalledSourcePath(sourcePath) {
4880
4961
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
4881
4962
  }
4882
4963
  function resolveCanonVersion(explicit) {
4883
- return explicit ?? "3.1.0" ?? "dev";
4964
+ return explicit ?? "3.1.2" ?? "dev";
4884
4965
  }
4885
4966
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
4886
4967
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
4887
4968
  if (!ownToplevel) return upstreamCommit;
4888
- const parentDir = path11.dirname(repoRoot);
4969
+ const parentDir = path12.dirname(repoRoot);
4889
4970
  const parentToplevel = captureGitOutput(parentDir, ["rev-parse", "--show-toplevel"], runGitAt);
4890
4971
  if (!parentToplevel) return upstreamCommit;
4891
- if (path11.resolve(parentToplevel) === path11.resolve(ownToplevel)) {
4972
+ if (path12.resolve(parentToplevel) === path12.resolve(ownToplevel)) {
4892
4973
  return upstreamCommit;
4893
4974
  }
4894
- return captureGitOutput(path11.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
4975
+ return captureGitOutput(path12.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
4895
4976
  }
4896
4977
  function defaultRunCommand(command, args) {
4897
4978
  const result = spawnSync4(command, args, {
@@ -4925,7 +5006,7 @@ function captureCanonSnapshot(repoRoot = REPO_ROOT, options = {}) {
4925
5006
  const isInstalled = isInstalledSourcePath(canonSourcePath);
4926
5007
  const superprojectWorkingTree = captureGitOutput(repoRoot, ["rev-parse", "--show-superproject-working-tree"], runGitAt);
4927
5008
  const drivingCommit = captureGitOutput(repoRoot, ["rev-parse", "HEAD"], runGitAt) || "<unavailable>";
4928
- const hostCommit = superprojectWorkingTree ? captureGitOutput(path11.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
5009
+ const hostCommit = superprojectWorkingTree ? captureGitOutput(path12.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
4929
5010
  let upstreamCommit;
4930
5011
  let orchestratorCommit;
4931
5012
  if (isInstalled) {
@@ -4959,14 +5040,14 @@ function applyCanonSnapshot(status, canon) {
4959
5040
  return next;
4960
5041
  }
4961
5042
  function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
4962
- const status = JSON.parse(fs11.readFileSync(statusFilePath, "utf8"));
5043
+ const status = JSON.parse(fs12.readFileSync(statusFilePath, "utf8"));
4963
5044
  const canon = captureCanonSnapshot(REPO_ROOT, options);
4964
5045
  const next = applyCanonSnapshot(status, canon);
4965
5046
  const serialized = `${JSON.stringify(next, null, 2)}
4966
5047
  `;
4967
- const current = fs11.readFileSync(statusFilePath, "utf8");
5048
+ const current = fs12.readFileSync(statusFilePath, "utf8");
4968
5049
  if (current !== serialized) {
4969
- fs11.writeFileSync(statusFilePath, serialized, "utf8");
5050
+ fs12.writeFileSync(statusFilePath, serialized, "utf8");
4970
5051
  }
4971
5052
  return canon;
4972
5053
  }
@@ -4975,8 +5056,8 @@ function refreshCanonSnapshotsAtPaths(statusFilePaths, options = {}) {
4975
5056
  }
4976
5057
 
4977
5058
  // src/orchestrator/quality-log.ts
4978
- import fs12 from "fs";
4979
- import path12 from "path";
5059
+ import fs13 from "fs";
5060
+ import path13 from "path";
4980
5061
  var CANON_LOG_HEADERS = [
4981
5062
  "Date",
4982
5063
  "Task",
@@ -5017,7 +5098,7 @@ var JUDGMENT_LABELS = {
5017
5098
  "notes": "Notes"
5018
5099
  };
5019
5100
  function getQualityLogFile(activeCwd) {
5020
- return process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path12.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path12.join(activeCwd, "docs/task-quality-log.md");
5101
+ return process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path13.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path13.join(activeCwd, "docs/task-quality-log.md");
5021
5102
  }
5022
5103
  function normalizeCellValue(value) {
5023
5104
  return value.replace(/\r\n|\n/g, " ");
@@ -5170,11 +5251,11 @@ function renderRowLine(headerCells, row) {
5170
5251
  function writeFileAtomic(filePath, content) {
5171
5252
  const tempPath = `${filePath}.tmp`;
5172
5253
  try {
5173
- fs12.writeFileSync(tempPath, content, "utf8");
5174
- fs12.renameSync(tempPath, filePath);
5254
+ fs13.writeFileSync(tempPath, content, "utf8");
5255
+ fs13.renameSync(tempPath, filePath);
5175
5256
  } finally {
5176
5257
  try {
5177
- fs12.unlinkSync(tempPath);
5258
+ fs13.unlinkSync(tempPath);
5178
5259
  } catch {
5179
5260
  }
5180
5261
  }
@@ -5183,7 +5264,7 @@ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
5183
5264
  try {
5184
5265
  let content;
5185
5266
  try {
5186
- content = fs12.readFileSync(logFilePath, "utf8");
5267
+ content = fs13.readFileSync(logFilePath, "utf8");
5187
5268
  } catch (error) {
5188
5269
  if (error.code === "ENOENT") {
5189
5270
  content = STANDARD_QUALITY_LOG_SKELETON;
@@ -5242,7 +5323,7 @@ function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
5242
5323
  try {
5243
5324
  let doneContent = "";
5244
5325
  try {
5245
- doneContent = fs12.readFileSync(donePath, "utf8");
5326
+ doneContent = fs13.readFileSync(donePath, "utf8");
5246
5327
  } catch {
5247
5328
  }
5248
5329
  upsertQualityLogRow(
@@ -5280,26 +5361,23 @@ function validateTaskId2(id) {
5280
5361
  throw new Error(`Error: invalid task ID '${id}'. Must not contain '..'.`);
5281
5362
  }
5282
5363
  }
5283
- function tasksRoot() {
5284
- return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
5285
- }
5286
5364
  function taskDirForCwd(_cwd, taskId) {
5287
5365
  const root = tasksRoot();
5288
- if (path13.isAbsolute(root)) {
5289
- return path13.join(root, taskId);
5366
+ if (path14.isAbsolute(root)) {
5367
+ return path14.join(root, taskId);
5290
5368
  }
5291
- return path13.join(resolveTaskCwd(taskId), root, taskId);
5369
+ return path14.join(resolveTaskCwd(taskId), root, taskId);
5292
5370
  }
5293
5371
  function taskStatusFileForCwd(cwd, taskId) {
5294
- return path13.join(taskDirForCwd(cwd, taskId), "status.json");
5372
+ return path14.join(taskDirForCwd(cwd, taskId), "status.json");
5295
5373
  }
5296
5374
  function taskRootForGate(cwd) {
5297
5375
  const root = tasksRoot();
5298
- return path13.isAbsolute(root) ? root : path13.join(cwd, root);
5376
+ return path14.isAbsolute(root) ? root : path14.join(cwd, root);
5299
5377
  }
5300
5378
  function readJsonFile(filePath) {
5301
5379
  try {
5302
- return JSON.parse(fs13.readFileSync(filePath, "utf8"));
5380
+ return JSON.parse(fs14.readFileSync(filePath, "utf8"));
5303
5381
  } catch (error) {
5304
5382
  const message = error instanceof Error ? error.message : String(error);
5305
5383
  throw new Error(`Error: failed to read ${filePath}: ${message}`);
@@ -5307,9 +5385,9 @@ function readJsonFile(filePath) {
5307
5385
  }
5308
5386
  function writeJsonAtomic(filePath, data) {
5309
5387
  const tmpFile = `${filePath}.tmp`;
5310
- fs13.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
5388
+ fs14.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
5311
5389
  `, "utf8");
5312
- fs13.renameSync(tmpFile, filePath);
5390
+ fs14.renameSync(tmpFile, filePath);
5313
5391
  }
5314
5392
  function writeStatusAtomic(filePath, status) {
5315
5393
  status.status = deriveTopLevelStatus(status);
@@ -5382,7 +5460,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
5382
5460
  assertValidVerdict(phaseArg, verdictArg);
5383
5461
  const taskCwd = resolveTaskCwd(id);
5384
5462
  const statusPath = taskStatusFileForCwd(taskCwd, id);
5385
- if (!fs13.existsSync(statusPath)) {
5463
+ if (!fs14.existsSync(statusPath)) {
5386
5464
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
5387
5465
  }
5388
5466
  const status = readJsonFile(statusPath);
@@ -5424,7 +5502,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
5424
5502
  writeQualityLogForTask(
5425
5503
  id,
5426
5504
  taskCwd,
5427
- path13.join(taskDirForCwd(taskCwd, id), "done.md"),
5505
+ path14.join(taskDirForCwd(taskCwd, id), "done.md"),
5428
5506
  status
5429
5507
  );
5430
5508
  }
@@ -5444,7 +5522,7 @@ function taskPhasePreflightRejected(id, phaseArg) {
5444
5522
  }
5445
5523
  const taskCwd = resolveTaskCwd(id);
5446
5524
  const statusPath = taskStatusFileForCwd(taskCwd, id);
5447
- if (!fs13.existsSync(statusPath)) {
5525
+ if (!fs14.existsSync(statusPath)) {
5448
5526
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
5449
5527
  }
5450
5528
  const status = readJsonFile(statusPath);
@@ -5599,10 +5677,10 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
5599
5677
  const failuresByTask = new Map(preflightFailed.map((failure) => [failure.taskId, failure]));
5600
5678
  const siblingTaskIds = preflightFailed.map((failure) => failure.taskId);
5601
5679
  for (const t of tasks) {
5602
- const reviewPath = path14.join(taskDirFor(t.taskId), "review.md");
5680
+ const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
5603
5681
  let existing = "";
5604
5682
  try {
5605
- existing = fs14.readFileSync(reviewPath, "utf8");
5683
+ existing = fs15.readFileSync(reviewPath, "utf8");
5606
5684
  } catch {
5607
5685
  }
5608
5686
  const hasH2Stage1 = /^## Stage 1\b/m.test(existing);
@@ -5618,7 +5696,7 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
5618
5696
  ${blockedBlock}` : `# Code Review: ${t.taskId}
5619
5697
 
5620
5698
  ${blockedBlock}`;
5621
- fs14.writeFileSync(reviewPath, reviewContent2, "utf8");
5699
+ fs15.writeFileSync(reviewPath, reviewContent2, "utf8");
5622
5700
  continue;
5623
5701
  }
5624
5702
  const currentPreflight = t.status.phases.code_review?.preflight_rejections_current_loop ?? 0;
@@ -5629,7 +5707,7 @@ ${blockedBlock}`;
5629
5707
  ---
5630
5708
 
5631
5709
  ${stub}` : stub;
5632
- fs14.writeFileSync(reviewPath, reviewContent, "utf8");
5710
+ fs15.writeFileSync(reviewPath, reviewContent, "utf8");
5633
5711
  }
5634
5712
  return true;
5635
5713
  }
@@ -5692,8 +5770,8 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
5692
5770
  process.exit(1);
5693
5771
  }
5694
5772
  for (const t of tasks) {
5695
- fs14.writeFileSync(
5696
- path14.join(taskDirFor(t.taskId), "review-cold-codex.md"),
5773
+ fs15.writeFileSync(
5774
+ path15.join(taskDirFor(t.taskId), "review-cold-codex.md"),
5697
5775
  coldReview.findings,
5698
5776
  "utf8"
5699
5777
  );
@@ -5709,10 +5787,10 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
5709
5787
  activeCwd
5710
5788
  }, activeCwd);
5711
5789
  for (const t of tasks) {
5712
- const reviewPath = path14.join(taskDirFor(t.taskId), "review.md");
5790
+ const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
5713
5791
  let reviewContent = null;
5714
5792
  try {
5715
- reviewContent = fs14.readFileSync(reviewPath, "utf8");
5793
+ reviewContent = fs15.readFileSync(reviewPath, "utf8");
5716
5794
  } catch {
5717
5795
  }
5718
5796
  if (isTemplateUnfilled(reviewContent)) {
@@ -5813,8 +5891,8 @@ async function runImplementPhase(state, interactive, resumeId, force = false) {
5813
5891
  }
5814
5892
 
5815
5893
  // src/orchestrator/phases/plan.ts
5816
- import fs15 from "fs";
5817
- import path15 from "path";
5894
+ import fs16 from "fs";
5895
+ import path16 from "path";
5818
5896
  async function runPlanPhase(state, interactive) {
5819
5897
  const { tasks } = state;
5820
5898
  const taskIds = tasks.map((t) => t.taskId);
@@ -5829,10 +5907,10 @@ async function runPlanPhase(state, interactive) {
5829
5907
  activeCwd
5830
5908
  }, activeCwd);
5831
5909
  for (const t of tasks) {
5832
- const planPath = path15.join(taskDirFor(t.taskId), "plan.md");
5910
+ const planPath = path16.join(taskDirFor(t.taskId), "plan.md");
5833
5911
  let planContent = null;
5834
5912
  try {
5835
- planContent = fs15.readFileSync(planPath, "utf8");
5913
+ planContent = fs16.readFileSync(planPath, "utf8");
5836
5914
  } catch {
5837
5915
  }
5838
5916
  if (isTemplateUnfilled(planContent)) {
@@ -5844,8 +5922,8 @@ async function runPlanPhase(state, interactive) {
5844
5922
  }
5845
5923
 
5846
5924
  // src/orchestrator/phases/qa.ts
5847
- import fs16 from "fs";
5848
- import path16 from "path";
5925
+ import fs17 from "fs";
5926
+ import path17 from "path";
5849
5927
  async function runQaPhase(state, interactive, resolvedPrTemplate) {
5850
5928
  const { tasks } = state;
5851
5929
  const taskIds = tasks.map((t) => t.taskId);
@@ -5862,11 +5940,11 @@ async function runQaPhase(state, interactive, resolvedPrTemplate) {
5862
5940
  }, activeCwd);
5863
5941
  if (!state.isBundle && result.capturedStdout) {
5864
5942
  const taskId = taskIds[0];
5865
- const donePath = path16.join(activeCwd, "tasks", taskId, "done.md");
5943
+ const donePath = path17.join(activeCwd, "tasks", taskId, "done.md");
5866
5944
  if (isDoneMdTemplate(donePath)) {
5867
5945
  const salvaged = extractDoneMdFromStdout(result.capturedStdout);
5868
5946
  if (salvaged) {
5869
- fs16.writeFileSync(donePath, salvaged);
5947
+ fs17.writeFileSync(donePath, salvaged);
5870
5948
  warn(`Salvaged tasks/${taskId}/done.md from captured stdout \u2014 QA sub-agent streamed content instead of using the Write tool.`);
5871
5949
  const phaseStatus = readStatus(taskId).phases.qa?.status ?? "pending";
5872
5950
  if (phaseStatus !== "done") {
@@ -5918,23 +5996,23 @@ async function runSpecPhase(state, interactive, resumeId) {
5918
5996
  }
5919
5997
 
5920
5998
  // src/orchestrator/phases/spec-review.ts
5921
- import fs17 from "fs";
5922
- import path17 from "path";
5999
+ import fs18 from "fs";
6000
+ import path18 from "path";
5923
6001
  function autoBlockSpecReview(taskIds, iterationCount, reason) {
5924
6002
  autoBlockPhase(taskIds, "spec_review", iterationCount, reason);
5925
6003
  }
5926
6004
  function recordFastTierSpecApproval(taskId) {
5927
- const artifactPath = path17.join(taskDirFor(taskId), "spec-review.md");
6005
+ const artifactPath = path18.join(taskDirFor(taskId), "spec-review.md");
5928
6006
  let content;
5929
6007
  try {
5930
- content = fs17.readFileSync(artifactPath, "utf8");
6008
+ content = fs18.readFileSync(artifactPath, "utf8");
5931
6009
  } catch {
5932
6010
  return;
5933
6011
  }
5934
6012
  if (extractCheckedVerdict(content)) return;
5935
6013
  const checked = content.replace(/^- \[ \] (\*\*Approved\*\*)/m, "- [x] $1");
5936
6014
  const note = "\n> Fast tier: Codex spec review skipped \u2014 human conversational spec approval recorded by the orchestrator.\n";
5937
- fs17.writeFileSync(
6015
+ fs18.writeFileSync(
5938
6016
  artifactPath,
5939
6017
  (checked !== content ? checked : `${content}
5940
6018
  ## Verdict
@@ -6003,10 +6081,10 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
6003
6081
  activeCwd
6004
6082
  }, activeCwd);
6005
6083
  for (const t of tasks) {
6006
- const reviewPath = path17.join(resolveTaskCwd(t.taskId), "tasks", t.taskId, "spec-review.md");
6084
+ const reviewPath = path18.join(resolveTaskCwd(t.taskId), "tasks", t.taskId, "spec-review.md");
6007
6085
  let reviewContent = null;
6008
6086
  try {
6009
- reviewContent = fs17.readFileSync(reviewPath, "utf8");
6087
+ reviewContent = fs18.readFileSync(reviewPath, "utf8");
6010
6088
  } catch {
6011
6089
  }
6012
6090
  if (isTemplateUnfilled(reviewContent)) {
@@ -6019,8 +6097,8 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
6019
6097
 
6020
6098
  // src/orchestrator/detach.ts
6021
6099
  import { spawn as spawn3 } from "child_process";
6022
- import fs18 from "fs";
6023
- import path18 from "path";
6100
+ import fs19 from "fs";
6101
+ import path19 from "path";
6024
6102
  var DETACH_CHILD_FLAG = "CANON_DETACHED";
6025
6103
  var DETACH_DISABLE_FLAG = "CANON_NO_DETACH";
6026
6104
  var PID_FILENAME = ".canon-pid";
@@ -6056,16 +6134,16 @@ function detachAndExit(options) {
6056
6134
  }
6057
6135
  const primaryDir = options.resolveTaskDir(options.taskIds[0]);
6058
6136
  try {
6059
- fs18.mkdirSync(primaryDir, { recursive: true });
6137
+ fs19.mkdirSync(primaryDir, { recursive: true });
6060
6138
  } catch (error) {
6061
6139
  stderrWrite(`canon: cannot create task dir for log file: ${error.message}
6062
6140
  `);
6063
6141
  return exit(1);
6064
6142
  }
6065
- const logPath = path18.join(primaryDir, LOG_FILENAME);
6143
+ const logPath = path19.join(primaryDir, LOG_FILENAME);
6066
6144
  let logFd;
6067
6145
  try {
6068
- logFd = fs18.openSync(logPath, "a");
6146
+ logFd = fs19.openSync(logPath, "a");
6069
6147
  } catch (error) {
6070
6148
  stderrWrite(`canon: cannot open ${logPath}: ${error.message}
6071
6149
  `);
@@ -6078,7 +6156,7 @@ function detachAndExit(options) {
6078
6156
  env: { ...process.env, [DETACH_CHILD_FLAG]: "1" }
6079
6157
  });
6080
6158
  try {
6081
- fs18.closeSync(logFd);
6159
+ fs19.closeSync(logFd);
6082
6160
  } catch {
6083
6161
  }
6084
6162
  if (child.pid == null) {
@@ -6089,8 +6167,8 @@ function detachAndExit(options) {
6089
6167
  for (const taskId of options.taskIds) {
6090
6168
  try {
6091
6169
  const dir = options.resolveTaskDir(taskId);
6092
- fs18.mkdirSync(dir, { recursive: true });
6093
- fs18.writeFileSync(path18.join(dir, PID_FILENAME), `${child.pid}
6170
+ fs19.mkdirSync(dir, { recursive: true });
6171
+ fs19.writeFileSync(path19.join(dir, PID_FILENAME), `${child.pid}
6094
6172
  `, "utf8");
6095
6173
  } catch (error) {
6096
6174
  pidWriteFailures.push({
@@ -6144,9 +6222,9 @@ ${rule}
6144
6222
  return exit(0);
6145
6223
  }
6146
6224
  function readCanonPid(taskDir) {
6147
- const file = path18.join(taskDir, PID_FILENAME);
6225
+ const file = path19.join(taskDir, PID_FILENAME);
6148
6226
  try {
6149
- const raw = fs18.readFileSync(file, "utf8").trim();
6227
+ const raw = fs19.readFileSync(file, "utf8").trim();
6150
6228
  const pid = Number.parseInt(raw, 10);
6151
6229
  return Number.isInteger(pid) && pid > 0 ? pid : null;
6152
6230
  } catch {
@@ -6155,7 +6233,7 @@ function readCanonPid(taskDir) {
6155
6233
  }
6156
6234
  function removeCanonPid(taskDir) {
6157
6235
  try {
6158
- fs18.unlinkSync(path18.join(taskDir, PID_FILENAME));
6236
+ fs19.unlinkSync(path19.join(taskDir, PID_FILENAME));
6159
6237
  } catch {
6160
6238
  }
6161
6239
  }
@@ -6422,7 +6500,7 @@ function autoCommitCode(taskIds, cwd = REPO_ROOT2) {
6422
6500
  for (const f of allHandoffFiles) {
6423
6501
  if (dirtyFiles.has(f)) continue;
6424
6502
  if (gitIgnoredHandoffFiles.has(f)) continue;
6425
- const exists = fs19.existsSync(path19.join(cwd, f));
6503
+ const exists = fs20.existsSync(path20.join(cwd, f));
6426
6504
  if (!exists) {
6427
6505
  const committed = gitSafeAt(cwd, "log", "--format=%H", "--max-count=1", `${baseRefForLog}..HEAD`, "--", f);
6428
6506
  if (committed.ok && committed.stdout.trim()) {
@@ -6612,30 +6690,30 @@ function exemptNodeModulesPath(entry, cwd, resolvedWorkspaceDirs) {
6612
6690
  if (entryPath === null) return null;
6613
6691
  if (matchesPorcelainPath(entryPath, "node_modules")) {
6614
6692
  return probeNodeModulesEntry(
6615
- path19.join(cwd, entryPath),
6616
- path19.join(REPO_ROOT2, entryPath)
6693
+ path20.join(cwd, entryPath),
6694
+ path20.join(REPO_ROOT2, entryPath)
6617
6695
  ).verdict === "verified-symlink" ? "node_modules" : null;
6618
6696
  }
6619
6697
  if (!isNodeModulesEntryPath(entryPath)) return null;
6620
6698
  let cwdReal;
6621
6699
  let repoRootReal;
6622
6700
  try {
6623
- cwdReal = fs19.realpathSync(cwd);
6624
- repoRootReal = fs19.realpathSync(REPO_ROOT2);
6701
+ cwdReal = fs20.realpathSync(cwd);
6702
+ repoRootReal = fs20.realpathSync(REPO_ROOT2);
6625
6703
  } catch {
6626
6704
  return null;
6627
6705
  }
6628
6706
  if (cwdReal === repoRootReal) return null;
6629
6707
  const workspaceDirs = resolvedWorkspaceDirs ?? resolveWorkspaceDirs(REPO_ROOT2);
6630
6708
  for (const workspace of workspaceDirs) {
6631
- const resolvedDestination = resolveContainedPath(path19.join(cwd, workspace), cwd);
6709
+ const resolvedDestination = resolveContainedPath(path20.join(cwd, workspace), cwd);
6632
6710
  if (resolvedDestination === null) continue;
6633
- const relativeDestination = path19.relative(cwdReal, resolvedDestination).split(path19.sep).join("/");
6711
+ const relativeDestination = path20.relative(cwdReal, resolvedDestination).split(path20.sep).join("/");
6634
6712
  const expectedEntryPath = `${relativeDestination}/node_modules`;
6635
6713
  if (!matchesPorcelainPath(entryPath, expectedEntryPath)) continue;
6636
6714
  return probeNodeModulesEntry(
6637
- path19.join(resolvedDestination, "node_modules"),
6638
- path19.join(REPO_ROOT2, workspace, "node_modules")
6715
+ path20.join(resolvedDestination, "node_modules"),
6716
+ path20.join(REPO_ROOT2, workspace, "node_modules")
6639
6717
  ).verdict === "verified-symlink" ? expectedEntryPath : null;
6640
6718
  }
6641
6719
  return null;
@@ -6677,15 +6755,15 @@ function buildHumanReviewStagePaths(taskIds, affectedManagedDocs, dirtyEntries,
6677
6755
  }
6678
6756
  function findPullRequestTemplate(repoRoot) {
6679
6757
  const candidates = [
6680
- path19.join(repoRoot, ".github", "pull_request_template.md"),
6681
- path19.join(repoRoot, ".github", "PULL_REQUEST_TEMPLATE.md"),
6682
- path19.join(repoRoot, "docs", "pull_request_template.md"),
6683
- path19.join(repoRoot, "docs", "PULL_REQUEST_TEMPLATE.md"),
6684
- path19.join(repoRoot, "pull_request_template.md"),
6685
- path19.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
6758
+ path20.join(repoRoot, ".github", "pull_request_template.md"),
6759
+ path20.join(repoRoot, ".github", "PULL_REQUEST_TEMPLATE.md"),
6760
+ path20.join(repoRoot, "docs", "pull_request_template.md"),
6761
+ path20.join(repoRoot, "docs", "PULL_REQUEST_TEMPLATE.md"),
6762
+ path20.join(repoRoot, "pull_request_template.md"),
6763
+ path20.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
6686
6764
  ];
6687
6765
  for (const candidate of candidates) {
6688
- if (fs19.existsSync(candidate)) return candidate;
6766
+ if (fs20.existsSync(candidate)) return candidate;
6689
6767
  }
6690
6768
  return null;
6691
6769
  }
@@ -6699,13 +6777,13 @@ function resolveQaPrBody(taskIds, activeCwd) {
6699
6777
  if (taskIds.length !== 1) {
6700
6778
  return { kind: "fallback", reason: "bundle: per-task pr-body.md files are not combined in this version" };
6701
6779
  }
6702
- const prBodyPath = path19.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
6780
+ const prBodyPath = path20.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
6703
6781
  if (!isPrBodyTemplate(prBodyPath)) {
6704
6782
  return { kind: "body-file", path: prBodyPath };
6705
6783
  }
6706
6784
  return {
6707
6785
  kind: "fallback",
6708
- reason: fs19.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
6786
+ reason: fs20.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
6709
6787
  };
6710
6788
  }
6711
6789
  function commitQaArtifacts(taskIds, cwd) {
@@ -6826,15 +6904,15 @@ function formatExistingPRMessage(prNum, prUrl) {
6826
6904
  return `Existing draft PR: #${prNum} (${prUrl})`;
6827
6905
  }
6828
6906
  function sidecarPathFor(taskId, taskDir = taskDirFor2(taskId)) {
6829
- return path19.join(taskDir, ".pr-number");
6907
+ return path20.join(taskDir, ".pr-number");
6830
6908
  }
6831
6909
  function recordPinnedPRNumber(taskIds, prNum) {
6832
6910
  const alreadyPinned = taskIds.every((taskId) => readSidecarPRNumber(taskId) === prNum);
6833
6911
  if (alreadyPinned) return;
6834
6912
  for (const taskId of taskIds) {
6835
6913
  const sidecarPath = sidecarPathFor(taskId);
6836
- fs19.mkdirSync(path19.dirname(sidecarPath), { recursive: true });
6837
- fs19.writeFileSync(sidecarPath, String(prNum), "utf8");
6914
+ fs20.mkdirSync(path20.dirname(sidecarPath), { recursive: true });
6915
+ fs20.writeFileSync(sidecarPath, String(prNum), "utf8");
6838
6916
  }
6839
6917
  }
6840
6918
  function reportOrCreatePR(taskIds, branchName) {
@@ -6954,8 +7032,8 @@ function commitHumanReviewFiles(taskIds, cwd, createPR) {
6954
7032
  ` + baseDivergenceResult.commits.map((commit) => ` ${commit.sha.slice(0, 7)} ${commit.subject}`).join("\n")
6955
7033
  );
6956
7034
  }
6957
- const docsRefsScript = path19.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
6958
- if (fs19.existsSync(docsRefsScript)) {
7035
+ const docsRefsScript = path20.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
7036
+ if (fs20.existsSync(docsRefsScript)) {
6959
7037
  const docsRefsResult = spawnSync6("node", [docsRefsScript], {
6960
7038
  cwd,
6961
7039
  stdio: ["ignore", "pipe", "pipe"],
@@ -7317,7 +7395,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
7317
7395
  const sidecarPath = sidecarPathFor(taskId, taskDir);
7318
7396
  let raw;
7319
7397
  try {
7320
- raw = fs19.readFileSync(sidecarPath, "utf8").trim();
7398
+ raw = fs20.readFileSync(sidecarPath, "utf8").trim();
7321
7399
  } catch {
7322
7400
  return null;
7323
7401
  }
@@ -7328,7 +7406,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
7328
7406
  }
7329
7407
  function resolveProofPRNumberForPrefetch(taskId, branchName, baseBranch, taskCwd) {
7330
7408
  if (!ghAvailable) return null;
7331
- const pinnedPrNum = readSidecarPRNumber(taskId, path19.join(taskCwd, "tasks", taskId));
7409
+ const pinnedPrNum = readSidecarPRNumber(taskId, path20.join(taskCwd, "tasks", taskId));
7332
7410
  if (pinnedPrNum !== null) return pinnedPrNum;
7333
7411
  return findOpenPRNumber(branchName, baseBranch) ?? findMergedPRNumber(branchName, baseBranch);
7334
7412
  }
@@ -7359,7 +7437,7 @@ function establishPRHeadAncestryProof(cwd, prNum, prHead, localTip) {
7359
7437
  return { proven: true };
7360
7438
  }
7361
7439
  function establishMergeProof(taskId, branchName, localTip, baseBranch, cwd, prefetchedHeads) {
7362
- const pinnedPrNum = readSidecarPRNumber(taskId, path19.join(cwd, "tasks", taskId));
7440
+ const pinnedPrNum = readSidecarPRNumber(taskId, path20.join(cwd, "tasks", taskId));
7363
7441
  if (ghAvailable && pinnedPrNum !== null) {
7364
7442
  if (!isPRMerged(pinnedPrNum)) {
7365
7443
  return { proven: false, reason: `Pinned PR #${pinnedPrNum} is not in MERGED state.` };
@@ -7463,8 +7541,8 @@ function mergeOpenPRsAndPull(taskIds, baseBranch, branchByTaskId) {
7463
7541
  return anyMerged;
7464
7542
  }
7465
7543
  function runPostMergeHook() {
7466
- const hookPath = path19.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
7467
- if (!fs19.existsSync(hookPath)) return;
7544
+ const hookPath = path20.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
7545
+ if (!fs20.existsSync(hookPath)) return;
7468
7546
  info2("Running .canon/hooks/post-merge.sh...");
7469
7547
  const result = runCommand2("bash", [hookPath]);
7470
7548
  if (!result.ok) {
@@ -7488,12 +7566,12 @@ function commitArchiveChanges(taskIds, baseBranch) {
7488
7566
  }
7489
7567
  function rewriteArchivedTaskRefs(taskIds) {
7490
7568
  const targets = [
7491
- path19.join(REPO_ROOT2, "docs", "lessons-learned.md"),
7492
- path19.join(REPO_ROOT2, "docs", "task-quality-log.md")
7569
+ path20.join(REPO_ROOT2, "docs", "lessons-learned.md"),
7570
+ path20.join(REPO_ROOT2, "docs", "task-quality-log.md")
7493
7571
  ];
7494
7572
  for (const filePath of targets) {
7495
- if (!fs19.existsSync(filePath)) continue;
7496
- let content = fs19.readFileSync(filePath, "utf8");
7573
+ if (!fs20.existsSync(filePath)) continue;
7574
+ let content = fs20.readFileSync(filePath, "utf8");
7497
7575
  let changed = false;
7498
7576
  for (const taskId of taskIds) {
7499
7577
  const stale = `tasks/${taskId}/`;
@@ -7504,8 +7582,8 @@ function rewriteArchivedTaskRefs(taskIds) {
7504
7582
  }
7505
7583
  }
7506
7584
  if (changed) {
7507
- fs19.writeFileSync(filePath, content, "utf8");
7508
- info2(`Updated stale task refs in ${path19.relative(REPO_ROOT2, filePath)}.`);
7585
+ fs20.writeFileSync(filePath, content, "utf8");
7586
+ info2(`Updated stale task refs in ${path20.relative(REPO_ROOT2, filePath)}.`);
7509
7587
  }
7510
7588
  }
7511
7589
  }
@@ -7538,7 +7616,7 @@ function classifyAndPreserveSharedDocDirt() {
7538
7616
  if (porcelainCode !== " M") {
7539
7617
  return { relPath, docClass, porcelainCode, headContent: null, workingContent: null };
7540
7618
  }
7541
- const workingContent = fs19.readFileSync(path19.join(REPO_ROOT2, relPath), "utf8");
7619
+ const workingContent = fs20.readFileSync(path20.join(REPO_ROOT2, relPath), "utf8");
7542
7620
  const headResult = gitSafeAtRaw(REPO_ROOT2, "show", `HEAD:${relPath}`);
7543
7621
  return {
7544
7622
  relPath,
@@ -7555,11 +7633,11 @@ function classifyAndPreserveSharedDocDirt() {
7555
7633
  }
7556
7634
  const preserve = verdict.preserve;
7557
7635
  if (preserve.length === 0) return [];
7558
- const backupDir = fs19.mkdtempSync(path19.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
7636
+ const backupDir = fs20.mkdtempSync(path20.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
7559
7637
  const preserved = [];
7560
7638
  for (const { relPath, suffix } of preserve) {
7561
- const backupPath = path19.join(backupDir, relPath.replace(/[\\/]/g, "__"));
7562
- fs19.writeFileSync(backupPath, suffix, "utf8");
7639
+ const backupPath = path20.join(backupDir, relPath.replace(/[\\/]/g, "__"));
7640
+ fs20.writeFileSync(backupPath, suffix, "utf8");
7563
7641
  info2(`Preserving uncommitted ${relPath} dirt during --ship; backup: ${backupPath}`);
7564
7642
  const checkoutResult = gitSafe("checkout", "HEAD", "--", relPath);
7565
7643
  if (!checkoutResult.ok) {
@@ -7573,21 +7651,21 @@ function shipTasks(taskIds) {
7573
7651
  const resolveShipCwd = (taskId) => {
7574
7652
  const tasksDirOverride = process.env.CANON_TASKS_DIR_OVERRIDE;
7575
7653
  if (tasksDirOverride) {
7576
- return path19.dirname(tasksDirOverride);
7654
+ return path20.dirname(tasksDirOverride);
7577
7655
  }
7578
7656
  if (isOrphanedWorktreeState(taskId)) return REPO_ROOT2;
7579
- return path19.dirname(path19.dirname(taskDirFor2(taskId)));
7657
+ return path20.dirname(path20.dirname(taskDirFor2(taskId)));
7580
7658
  };
7581
7659
  const taskStatuses = /* @__PURE__ */ new Map();
7582
7660
  const readShipStatus = (taskId) => {
7583
7661
  const taskCwd = resolveShipCwd(taskId);
7584
7662
  const candidates = [
7585
- path19.join(taskCwd, "tasks", taskId, "status.json"),
7586
- path19.join(taskCwd, taskId, "status.json"),
7587
- path19.join(taskDirForRepoRoot2(taskId), "status.json")
7663
+ path20.join(taskCwd, "tasks", taskId, "status.json"),
7664
+ path20.join(taskCwd, taskId, "status.json"),
7665
+ path20.join(taskDirForRepoRoot2(taskId), "status.json")
7588
7666
  ];
7589
7667
  for (const candidate of candidates) {
7590
- if (fs19.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
7668
+ if (fs20.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
7591
7669
  }
7592
7670
  const snapshot = taskStatuses.get(taskId);
7593
7671
  if (snapshot) return snapshot;
@@ -7619,7 +7697,7 @@ function shipTasks(taskIds) {
7619
7697
  const currentPhase = getCurrentPhase(readShipStatus(taskId));
7620
7698
  if (currentPhase !== "human_review") continue;
7621
7699
  const taskCwd = resolveShipCwd(taskId);
7622
- const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path19.join(taskCwd, "tasks");
7700
+ const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path20.join(taskCwd, "tasks");
7623
7701
  const gateResult = checkPhaseGate(
7624
7702
  taskId,
7625
7703
  "human_review",
@@ -7654,7 +7732,7 @@ function shipTasks(taskIds) {
7654
7732
  if (taskIds.some((id) => taskSnapshot(id).worktree)) {
7655
7733
  preservedSharedDocDirt = classifyAndPreserveSharedDocDirt();
7656
7734
  }
7657
- const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) => path19.join("tasks", taskId, "status.json"));
7735
+ const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) => path20.join("tasks", taskId, "status.json"));
7658
7736
  if (orphanedStatusPaths.length > 0) {
7659
7737
  gitSafe("checkout", "HEAD", "--", ...orphanedStatusPaths);
7660
7738
  }
@@ -7727,8 +7805,8 @@ Recovery:
7727
7805
  - --force does not bypass this gate.`
7728
7806
  );
7729
7807
  }
7730
- const archiveDir = path19.join(TASKS_DIR2, "_archive");
7731
- if (!fs19.existsSync(archiveDir)) fs19.mkdirSync(archiveDir, { recursive: true });
7808
+ const archiveDir = path20.join(TASKS_DIR2, "_archive");
7809
+ if (!fs20.existsSync(archiveDir)) fs20.mkdirSync(archiveDir, { recursive: true });
7732
7810
  const localBranchesToDelete = [];
7733
7811
  for (const taskId of taskIds) {
7734
7812
  const { worktree: hasWorktree } = taskSnapshot(taskId);
@@ -7737,27 +7815,27 @@ Recovery:
7737
7815
  status.updated = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
7738
7816
  const humanReview = status.phases.human_review;
7739
7817
  if (humanReview) humanReview.status = "done";
7740
- writeStatusToFile(path19.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
7818
+ writeStatusToFile(path20.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
7741
7819
  const src = taskDirForRepoRoot2(taskId);
7742
- const dest = path19.join(archiveDir, taskId);
7743
- fs19.renameSync(src, dest);
7820
+ const dest = path20.join(archiveDir, taskId);
7821
+ fs20.renameSync(src, dest);
7744
7822
  info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
7745
7823
  const branchName = taskSnapshot(taskId).branch;
7746
7824
  if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
7747
7825
  }
7748
7826
  rewriteArchivedTaskRefs(taskIds);
7749
7827
  const stagedPaths = taskIds.flatMap((id) => [
7750
- path19.join(TASKS_DIR2, id),
7828
+ path20.join(TASKS_DIR2, id),
7751
7829
  // deleted source (if not cleaned up)
7752
- path19.join(TASKS_DIR2, "_archive", id),
7830
+ path20.join(TASKS_DIR2, "_archive", id),
7753
7831
  // new archive destination
7754
- path19.join(REPO_ROOT2, "docs", "lessons-learned.md"),
7755
- path19.join(REPO_ROOT2, "docs", "task-quality-log.md")
7832
+ path20.join(REPO_ROOT2, "docs", "lessons-learned.md"),
7833
+ path20.join(REPO_ROOT2, "docs", "task-quality-log.md")
7756
7834
  ]);
7757
7835
  stageArchiveChanges(stagedPaths);
7758
7836
  for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
7759
- fs19.appendFileSync(path19.join(REPO_ROOT2, relPath), suffix, "utf8");
7760
- fs19.rmSync(backupPath, { force: true });
7837
+ fs20.appendFileSync(path20.join(REPO_ROOT2, relPath), suffix, "utf8");
7838
+ fs20.rmSync(backupPath, { force: true });
7761
7839
  info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
7762
7840
  }
7763
7841
  const archiveCommit = commitArchiveChanges(taskIds, baseBranch);
@@ -7818,7 +7896,7 @@ function rerouteFromHumanReview(taskIds) {
7818
7896
  if (!result.amended) {
7819
7897
  amendmentFailures.push({
7820
7898
  taskId,
7821
- specPath: path19.join(taskDirFor2(taskId), "spec.md"),
7899
+ specPath: path20.join(taskDirFor2(taskId), "spec.md"),
7822
7900
  requiredRound,
7823
7901
  expectedHeading: requiredRound === 1 ? "## Amendment" : `## Amendment Round ${requiredRound}`,
7824
7902
  reason: result.reason
@@ -7855,8 +7933,9 @@ function rerouteFromHumanReview(taskIds) {
7855
7933
  const archivedReviewByTask = /* @__PURE__ */ new Map();
7856
7934
  for (const taskId of taskIds) {
7857
7935
  let archivedReview = null;
7936
+ const scaffoldIdentity = { taskId, title: readStatus2(taskId).title ?? "" };
7858
7937
  try {
7859
- archivedReview = archivePriorReview(taskDirFor2(taskId), { skipUnfilledTemplate: true });
7938
+ archivedReview = archivePriorReview(taskDirFor2(taskId), { skipUnfilledTemplate: true, scaffold: scaffoldIdentity });
7860
7939
  } catch (error) {
7861
7940
  const message = error instanceof Error ? error.message : String(error);
7862
7941
  const completedArchives = [...archivedReviewByTask.entries()].filter((entry) => entry[1] !== null).map(([completedTaskId, archiveName]) => `${completedTaskId} \u2192 ${archiveName}`).join(", ") || "none";
@@ -7871,6 +7950,18 @@ function rerouteFromHumanReview(taskIds) {
7871
7950
  `Archived tasks/${taskId}/review.md \u2192 ${archivedReview} (post-reroute review starts a fresh round 1).`
7872
7951
  );
7873
7952
  }
7953
+ const rescaffold = rescaffoldReview(taskDirFor2(taskId), scaffoldIdentity);
7954
+ if (rescaffold.outcome === "written") {
7955
+ info(`Re-scaffolded tasks/${taskId}/review.md from ${path20.relative(process.cwd(), rescaffold.source)}.`);
7956
+ } else if (rescaffold.outcome === "no-template") {
7957
+ warn2(
7958
+ `tasks/${taskId}/review.md is missing and no template was found to re-scaffold it (looked for a review.md override under tasks/_templates/ and .canon/templates/review.md). The code-review foreman will have to write the file from scratch.`
7959
+ );
7960
+ } else if (rescaffold.outcome === "error") {
7961
+ warn2(
7962
+ `tasks/${taskId}/review.md is missing and could not be re-scaffolded: ${rescaffold.message}. The code-review foreman will have to write the file from scratch.`
7963
+ );
7964
+ }
7874
7965
  }
7875
7966
  let clearedFullSend = false;
7876
7967
  for (const taskId of taskIds) {
@@ -7995,7 +8086,7 @@ async function runPhase(phase, state) {
7995
8086
  if (phase === "qa") {
7996
8087
  const activeCwd = getActiveCwd(taskIds);
7997
8088
  const qaTemplatePath = state.isBundle ? null : findPullRequestTemplate(activeCwd) ?? findPullRequestTemplate(REPO_ROOT2);
7998
- const resolvedPrTemplate = qaTemplatePath ? fs19.readFileSync(qaTemplatePath, "utf8") : null;
8089
+ const resolvedPrTemplate = qaTemplatePath ? fs20.readFileSync(qaTemplatePath, "utf8") : null;
7999
8090
  return runQaPhase(state, cliArgs.interactive, resolvedPrTemplate);
8000
8091
  }
8001
8092
  if (phase === "human_review") {
@@ -8009,7 +8100,7 @@ async function runPhase(phase, state) {
8009
8100
  }
8010
8101
  const branch = [...branches][0];
8011
8102
  const cwd = getActiveCwd(taskIds2);
8012
- const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path19.join(cwd, "tasks");
8103
+ const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path20.join(cwd, "tasks");
8013
8104
  for (const taskId of taskIds2) {
8014
8105
  const gateResult = checkPhaseGate(taskId, "human_review", void 0, tasksRootForGate);
8015
8106
  if (!gateResult.ok) {
@@ -8076,9 +8167,9 @@ async function runPhase(phase, state) {
8076
8167
  }
8077
8168
  var extractCheckedVerdict2 = extractCheckedVerdict;
8078
8169
  function readArtifact(taskId, name) {
8079
- const p = path19.join(taskDirFor2(taskId), name);
8170
+ const p = path20.join(taskDirFor2(taskId), name);
8080
8171
  try {
8081
- return fs19.readFileSync(p, "utf8");
8172
+ return fs20.readFileSync(p, "utf8");
8082
8173
  } catch {
8083
8174
  return null;
8084
8175
  }
@@ -8094,15 +8185,15 @@ function checkImplementEvidence(taskId) {
8094
8185
  return { advanced: false, note: `handoff.md Changes table has malformed row(s): ${sample}${tail}` };
8095
8186
  }
8096
8187
  const issues = validateHandoffAgainstSpec(
8097
- path19.join(taskDirFor2(taskId), "spec.md"),
8098
- path19.join(taskDirFor2(taskId), "handoff.md")
8188
+ path20.join(taskDirFor2(taskId), "spec.md"),
8189
+ path20.join(taskDirFor2(taskId), "handoff.md")
8099
8190
  );
8100
8191
  if (issues.length > 0) return { advanced: false, note: `handoff.md validation failed: ${issues.join("; ")}` };
8101
8192
  const checkRoots = [REPO_ROOT2];
8102
8193
  const sForEvidence = readStatus(taskId);
8103
8194
  if (sForEvidence.worktree === true) {
8104
8195
  const wt = worktreePath(taskId);
8105
- if (fs19.existsSync(wt)) checkRoots.push(wt);
8196
+ if (fs20.existsSync(wt)) checkRoots.push(wt);
8106
8197
  }
8107
8198
  const ignoreCwd = checkRoots[checkRoots.length - 1];
8108
8199
  const gitIgnored = filterGitIgnoredPaths(files, ignoreCwd);
@@ -8114,7 +8205,7 @@ function checkImplementEvidence(taskId) {
8114
8205
  };
8115
8206
  }
8116
8207
  const existingFiles = verifiableFiles.filter(
8117
- (f) => checkRoots.some((root) => fs19.existsSync(path19.join(root, f)))
8208
+ (f) => checkRoots.some((root) => fs20.existsSync(path20.join(root, f)))
8118
8209
  );
8119
8210
  if (existingFiles.length === 0) {
8120
8211
  const evidenceCwd = checkRoots[checkRoots.length - 1];
@@ -8198,7 +8289,7 @@ function tryEvidenceAdvance(taskId, phase) {
8198
8289
  return { advanced: true, note: "spec.md is populated" };
8199
8290
  }
8200
8291
  case "qa": {
8201
- const donePath = path19.join(taskDirFor(taskId), "done.md");
8292
+ const donePath = path20.join(taskDirFor(taskId), "done.md");
8202
8293
  if (isDoneMdTemplate(donePath)) return { advanced: false, note: "done.md is still the template" };
8203
8294
  taskPhase(taskId, "qa", "done");
8204
8295
  return { advanced: true, note: "done.md is populated" };
@@ -8469,9 +8560,9 @@ function checkDeps(taskIds, skipAgentDeps = false) {
8469
8560
  }
8470
8561
  for (const taskId of taskIds) {
8471
8562
  validateTaskId(taskId);
8472
- const repoRootStatusFile = path19.join(REPO_ROOT2, "tasks", taskId, "status.json");
8473
- const statusFile = cliArgs.ship && fs19.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
8474
- if (!fs19.existsSync(statusFile)) {
8563
+ const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", taskId, "status.json");
8564
+ const statusFile = cliArgs.ship && fs20.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
8565
+ if (!fs20.existsSync(statusFile)) {
8475
8566
  die(`No status.json at tasks/${taskId}/status.json \u2014 run canon task new ${taskId} first`);
8476
8567
  }
8477
8568
  }
@@ -8512,8 +8603,8 @@ async function main() {
8512
8603
  const earlyHeartbeatTaskIds = cliArgs.taskIds;
8513
8604
  let heartbeatStarted = false;
8514
8605
  const earlyHeartbeatResolver = (id) => {
8515
- const repoRootStatusFile = path19.join(REPO_ROOT2, "tasks", id, "status.json");
8516
- return path19.dirname(cliArgs.ship && fs19.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
8606
+ const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", id, "status.json");
8607
+ return path20.dirname(cliArgs.ship && fs20.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
8517
8608
  };
8518
8609
  if (!cliArgs.ship && !cliArgs.dryRun) {
8519
8610
  guardConcurrentRun(cliArgs.taskIds, earlyHeartbeatResolver);
@@ -8549,7 +8640,7 @@ async function main() {
8549
8640
  }
8550
8641
  refreshCanonSnapshotsAtPaths(taskIds.map(statusFileFor));
8551
8642
  const initialState = buildPipelineState(taskIds);
8552
- const heartbeatDirResolver = (id) => path19.dirname(statusFileFor(id));
8643
+ const heartbeatDirResolver = (id) => path20.dirname(statusFileFor(id));
8553
8644
  if (!isSynchronousMode(cliArgs) && shouldAutoDetach()) {
8554
8645
  detachAndExit({
8555
8646
  taskIds,