canon-ai 3.3.0 → 3.4.0

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.
@@ -79,12 +79,12 @@ import { pathToFileURL } from "url";
79
79
 
80
80
  // src/orchestrator/main.ts
81
81
  import { spawnSync as spawnSync6 } from "child_process";
82
- import fs20 from "fs";
82
+ import fs21 from "fs";
83
83
  import os from "os";
84
84
  import path20 from "path";
85
85
 
86
86
  // src/orchestrator/phases/code-review.ts
87
- import fs15 from "fs";
87
+ import fs16 from "fs";
88
88
  import path15 from "path";
89
89
 
90
90
  // src/orchestrator/cli.ts
@@ -313,6 +313,7 @@ function isSynchronousMode(args) {
313
313
 
314
314
  // src/orchestrator/git.ts
315
315
  import { spawnSync as spawnSync3 } from "child_process";
316
+ import fs6 from "fs";
316
317
  import path5 from "path";
317
318
 
318
319
  // src/orchestrator/env.ts
@@ -1329,6 +1330,23 @@ function filterGitIgnoredPaths(paths, cwd) {
1329
1330
  const stdout = result.stdout ?? "";
1330
1331
  return new Set(stdout.split("\0").filter((p) => p.length > 0));
1331
1332
  }
1333
+ function filterStageablePaths(paths, cwd) {
1334
+ if (paths.length === 0) return [];
1335
+ return paths.filter((candidate) => {
1336
+ const probe = spawnSync3("git", ["ls-files", "--error-unmatch", "--", candidate], {
1337
+ cwd,
1338
+ stdio: ["ignore", "ignore", "ignore"]
1339
+ });
1340
+ if (probe.error || probe.status !== 1) return true;
1341
+ try {
1342
+ fs6.lstatSync(path5.join(cwd, candidate));
1343
+ return true;
1344
+ } catch (error) {
1345
+ const code = error.code;
1346
+ return code !== "ENOENT" && code !== "ENOTDIR";
1347
+ }
1348
+ });
1349
+ }
1332
1350
  function commitTaskArtifactsToBase(taskIds, _artifactFiles) {
1333
1351
  void _artifactFiles;
1334
1352
  for (const taskId of taskIds) {
@@ -1410,7 +1428,10 @@ function truncateUtf8(input, capBytes) {
1410
1428
  return bytes.subarray(0, end).toString("utf8");
1411
1429
  }
1412
1430
  function getScopedDiff(baseBranch, cwd, capBytes = 5e4) {
1413
- const result = gitSafeAtRaw(cwd, "diff", `${baseBranch}...HEAD`);
1431
+ return getScopedDiffInRange(`${baseBranch}...HEAD`, cwd, capBytes);
1432
+ }
1433
+ function getScopedDiffInRange(rangeExpr, cwd, capBytes = 5e4) {
1434
+ const result = gitSafeAtRaw(cwd, "diff", rangeExpr);
1414
1435
  if (!result.ok) return null;
1415
1436
  const raw = result.stdout;
1416
1437
  if (Buffer.byteLength(raw, "utf8") <= capBytes) {
@@ -1574,10 +1595,34 @@ function parseNameStatusOutput(raw) {
1574
1595
  return [...paths].sort();
1575
1596
  }
1576
1597
  function getAffectedFiles(baseRef, cwd) {
1577
- const result = gitSafeAtRaw(cwd, "diff", `${baseRef}...HEAD`, "--name-status", "-M", "-z");
1578
- if (!result.ok || !result.stdout) return [];
1598
+ return getPathsInRange(`${baseRef}...HEAD`, cwd) ?? [];
1599
+ }
1600
+ function getPathsInRange(rangeExpr, cwd) {
1601
+ const result = gitSafeAtRaw(cwd, "diff", rangeExpr, "--name-status", "-M", "-z");
1602
+ if (!result.ok) return null;
1603
+ if (!result.stdout) return [];
1579
1604
  return parseNameStatusOutput(result.stdout);
1580
1605
  }
1606
+ function resolveCommit(ref, cwd) {
1607
+ const result = gitSafeAt(cwd, "rev-parse", "--verify", `${ref}^{commit}`);
1608
+ return result.ok ? result.stdout.trim() : null;
1609
+ }
1610
+ function isAncestorCommit(ancestorRef, descendantRef, cwd) {
1611
+ return gitSafeAt(cwd, "merge-base", "--is-ancestor", ancestorRef, descendantRef).ok;
1612
+ }
1613
+ function getDeltaLineStats(prevSha, cwd, headRef = "HEAD") {
1614
+ const result = gitSafeAtRaw(cwd, "diff", `${prevSha}..${headRef}`, "--numstat", "--no-renames");
1615
+ if (!result.ok) return null;
1616
+ const stats = result.stdout.split("\n").filter(Boolean).map((line) => {
1617
+ const [addedRaw, deletedRaw, ...pathParts] = line.split(" ");
1618
+ return {
1619
+ path: pathParts.join(" "),
1620
+ added: addedRaw === "-" ? 0 : Number(addedRaw),
1621
+ deleted: deletedRaw === "-" ? 0 : Number(deletedRaw)
1622
+ };
1623
+ });
1624
+ return stats.every((stat) => stat.path.length > 0 && Number.isFinite(stat.added) && Number.isFinite(stat.deleted)) ? stats : null;
1625
+ }
1581
1626
  function getTreeDriftFiles(baseRef, cwd) {
1582
1627
  const result = gitSafeAtRaw(cwd, "diff", baseRef, "HEAD", "--name-status", "-M", "-z");
1583
1628
  if (!result.ok) {
@@ -1587,6 +1632,31 @@ function getTreeDriftFiles(baseRef, cwd) {
1587
1632
  }
1588
1633
 
1589
1634
  // src/lib/pipeline-policy.ts
1635
+ var CODE_REVIEW_DELTA_LINE_THRESHOLD = 400;
1636
+ function isReviewOwnedPath(filePath, facts) {
1637
+ return facts.taskIds.some((id) => filePath === `tasks/${id}` || filePath.startsWith(`tasks/${id}/`)) || facts.telemetryFiles.includes(filePath);
1638
+ }
1639
+ function resolveCodeReviewScope(facts) {
1640
+ const full = (reason) => ({ scope: "full", base: facts.baseBranch, reason });
1641
+ if (facts.isRound1) return full("Round 1 (initial review)");
1642
+ if (facts.effectiveSize === "XL" || facts.delicate) return full(facts.delicate ? "delicate" : "XL task size");
1643
+ const prev = facts.prevRecord;
1644
+ if (prev === null) return full(facts.previousRecordsDisagree ? "bundle members disagree on the previous reviewed commit" : facts.previousArchiveMalformed ? "unparseable cold-Codex archive record for the previous round" : "no cold-Codex archive record for the previous round");
1645
+ if (!prev.exists) return full(`previous reviewed commit ${prev.reviewedSha} does not resolve`);
1646
+ if (!prev.isAncestor) return full(`previous reviewed commit ${prev.reviewedSha} is not an ancestor of HEAD`);
1647
+ if (prev.equalsHead) return full("previous reviewed commit equals HEAD");
1648
+ if (facts.deltaPaths === null) return full("delta path probe failed");
1649
+ if (facts.priorChangeSetPaths === null) return full("previous change-set path probe failed");
1650
+ if (facts.deltaFileStats === null) return full("delta line-stat probe failed");
1651
+ const priorPaths = new Set(facts.priorChangeSetPaths.filter((p) => !isReviewOwnedPath(p, facts)));
1652
+ const outside = facts.deltaPaths.find((p) => !isReviewOwnedPath(p, facts) && !priorPaths.has(p));
1653
+ if (outside) return full(`${outside} is outside the previous round's change set`);
1654
+ const lines = facts.deltaFileStats.filter((stat) => !isReviewOwnedPath(stat.path, facts)).reduce((sum, stat) => sum + stat.added + stat.deleted, 0);
1655
+ if (lines > CODE_REVIEW_DELTA_LINE_THRESHOLD) {
1656
+ return full(`delta line count ${lines} exceeds ${CODE_REVIEW_DELTA_LINE_THRESHOLD}`);
1657
+ }
1658
+ return { scope: "delta", base: prev.reviewedSha, reason: "delta" };
1659
+ }
1590
1660
  var SIZE_ORDER = ["XS", "S", "M", "L", "XL"];
1591
1661
  var SINGLE_PASS_BUDGET_BY_SIZE = {
1592
1662
  XS: "5.00",
@@ -1790,15 +1860,15 @@ function isPlanCombined2(status) {
1790
1860
  import { spawn as spawn2 } from "child_process";
1791
1861
 
1792
1862
  // src/orchestrator/metrics.ts
1793
- import fs6 from "fs";
1863
+ import fs7 from "fs";
1794
1864
  import path6 from "path";
1795
1865
  function getMetricsFile(activeCwd) {
1796
1866
  return process.env.CANON_METRICS_FILE_OVERRIDE ? path6.resolve(process.env.CANON_METRICS_FILE_OVERRIDE) : path6.join(activeCwd ?? REPO_ROOT, "docs/pipeline-invocations.md");
1797
1867
  }
1798
1868
  function recordMetric(entry) {
1799
1869
  const metricsFile = getMetricsFile(entry.activeCwd);
1800
- if (!fs6.existsSync(metricsFile)) {
1801
- fs6.writeFileSync(metricsFile, [
1870
+ if (!fs7.existsSync(metricsFile)) {
1871
+ fs7.writeFileSync(metricsFile, [
1802
1872
  "# Workflow Metrics",
1803
1873
  "",
1804
1874
  "> Auto-logged by canon's orchestrator. One row per agent invocation.",
@@ -1812,7 +1882,7 @@ function recordMetric(entry) {
1812
1882
  const safeCell = (v) => v.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
1813
1883
  const dur = (entry.durationMs / 1e3).toFixed(1) + "s";
1814
1884
  const tok = entry.tokens != null ? String(entry.tokens) : "-";
1815
- fs6.appendFileSync(
1885
+ fs7.appendFileSync(
1816
1886
  metricsFile,
1817
1887
  `| ${(/* @__PURE__ */ new Date()).toISOString()} | ${entry.taskId} | ${entry.phase} | ${entry.agent} | ${safeCell(entry.model)} | ${entry.iteration ?? "-"} | ${dur} | ${tok} | ${entry.status} |
1818
1888
  `
@@ -2434,7 +2504,7 @@ function evaluateCodeReviewLoop(tasks, cap) {
2434
2504
  }
2435
2505
 
2436
2506
  // src/orchestrator/validation.ts
2437
- import fs7 from "fs";
2507
+ import fs8 from "fs";
2438
2508
  import path7 from "path";
2439
2509
 
2440
2510
  // src/orchestrator/markdown-table.ts
@@ -2751,7 +2821,7 @@ function normalizeCheckLabel(value) {
2751
2821
  }
2752
2822
  function parseValidationRequiredChecks(specPath) {
2753
2823
  try {
2754
- const content = fs7.readFileSync(specPath, "utf8");
2824
+ const content = fs8.readFileSync(specPath, "utf8");
2755
2825
  const section = content.match(/## Validation Required\n\n([\s\S]*?)(?:\n## |\n# |$)/);
2756
2826
  if (!section) return null;
2757
2827
  const checks = [];
@@ -2838,16 +2908,17 @@ function checkRerouteEvidence(phase, artifactContent, status) {
2838
2908
  }
2839
2909
  return { reroute: true, ok: true };
2840
2910
  }
2911
+ var HUMAN_AMENDMENT_HEADING_RE = /^#{2,6}[ \t]+Amendment\b(?![ \t]*\(full-send scope\))/im;
2841
2912
  function verifyRerouteAmendment(taskId, requiredRound) {
2842
2913
  const specPath = path7.join(taskDirFor(taskId), "spec.md");
2843
2914
  let content;
2844
2915
  try {
2845
- content = fs7.readFileSync(specPath, "utf8");
2916
+ content = fs8.readFileSync(specPath, "utf8");
2846
2917
  } catch {
2847
2918
  return { amended: false, reason: `spec.md missing at ${specPath}` };
2848
2919
  }
2849
2920
  if (requiredRound === 1) {
2850
- if (/^#{2,6}[ \t]+Amendment\b/im.test(content)) {
2921
+ if (HUMAN_AMENDMENT_HEADING_RE.test(content)) {
2851
2922
  return { amended: true, reason: "" };
2852
2923
  }
2853
2924
  return {
@@ -2872,7 +2943,7 @@ function verifyRerouteAmendment(taskId, requiredRound) {
2872
2943
  reason: `found \`## Amendment Round ${seenRound}\` in ${specPath}, expected \`## Amendment Round ${requiredRound}\``
2873
2944
  };
2874
2945
  }
2875
- if (/^#{2,6}[ \t]+Amendment\b/im.test(content)) {
2946
+ if (HUMAN_AMENDMENT_HEADING_RE.test(content)) {
2876
2947
  return {
2877
2948
  amended: false,
2878
2949
  reason: `found \`## Amendment\` in ${specPath}, expected \`## Amendment Round ${requiredRound}\``
@@ -3017,7 +3088,7 @@ function classifyPreflightBlockers(taskId, changedFiles, bundleDiffIssues = [])
3017
3088
  const handoffPath = path7.join(taskDirFor(taskId), "handoff.md");
3018
3089
  const specPath = path7.join(taskDirFor(taskId), "spec.md");
3019
3090
  try {
3020
- const content = fs7.readFileSync(handoffPath, "utf8");
3091
+ const content = fs8.readFileSync(handoffPath, "utf8");
3021
3092
  const latestResults = computeLatestValidationResults(content);
3022
3093
  const requiredChecks = parseValidationRequiredChecks(specPath);
3023
3094
  const { malformed } = parseHandoffChangesRows(taskId);
@@ -3049,7 +3120,7 @@ function validateHandoffAgainstSpec(specPath, handoffPath, latestResults, change
3049
3120
  rowMap = latestResults;
3050
3121
  } else {
3051
3122
  try {
3052
- const content = fs7.readFileSync(handoffPath, "utf8");
3123
+ const content = fs8.readFileSync(handoffPath, "utf8");
3053
3124
  rowMap = computeLatestValidationResults(content);
3054
3125
  } catch {
3055
3126
  rowMap = /* @__PURE__ */ new Map();
@@ -3103,7 +3174,7 @@ function isTemplateUnfilled(content) {
3103
3174
  function isDoneMdTemplate(donePath) {
3104
3175
  let content;
3105
3176
  try {
3106
- content = fs7.readFileSync(donePath, "utf8");
3177
+ content = fs8.readFileSync(donePath, "utf8");
3107
3178
  } catch {
3108
3179
  return true;
3109
3180
  }
@@ -3112,7 +3183,7 @@ function isDoneMdTemplate(donePath) {
3112
3183
  function isPrBodyTemplate(prBodyPath) {
3113
3184
  let content;
3114
3185
  try {
3115
- content = fs7.readFileSync(prBodyPath, "utf8");
3186
+ content = fs8.readFileSync(prBodyPath, "utf8");
3116
3187
  } catch {
3117
3188
  return true;
3118
3189
  }
@@ -3158,7 +3229,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3158
3229
  const artifactPath = path7.join(taskDir, config3.artifactName);
3159
3230
  let content;
3160
3231
  try {
3161
- content = fs7.readFileSync(artifactPath, "utf8");
3232
+ content = fs8.readFileSync(artifactPath, "utf8");
3162
3233
  } catch {
3163
3234
  return { ok: false, reason: `${config3.artifactName} is missing for phase '${phase}'` };
3164
3235
  }
@@ -3170,7 +3241,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3170
3241
  if (phase === "spec_review" || phase === "plan") {
3171
3242
  let statusRaw;
3172
3243
  try {
3173
- statusRaw = fs7.readFileSync(path7.join(taskDir, "status.json"), "utf8");
3244
+ statusRaw = fs8.readFileSync(path7.join(taskDir, "status.json"), "utf8");
3174
3245
  } catch {
3175
3246
  return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
3176
3247
  }
@@ -3208,7 +3279,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3208
3279
  const handoffPath = path7.join(taskDir, "handoff.md");
3209
3280
  let handoffContent;
3210
3281
  try {
3211
- handoffContent = fs7.readFileSync(handoffPath, "utf8");
3282
+ handoffContent = fs8.readFileSync(handoffPath, "utf8");
3212
3283
  } catch {
3213
3284
  return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
3214
3285
  }
@@ -3217,7 +3288,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3217
3288
  const donePath = path7.join(taskDir, "done.md");
3218
3289
  let doneContent = "";
3219
3290
  try {
3220
- doneContent = fs7.readFileSync(donePath, "utf8");
3291
+ doneContent = fs8.readFileSync(donePath, "utf8");
3221
3292
  } catch {
3222
3293
  }
3223
3294
  if (hasHumanPendingWaiver(doneContent)) return { ok: true };
@@ -3238,7 +3309,7 @@ function parseHandoffChangesRows(taskId) {
3238
3309
  const handoffPath = path7.join(taskDirFor(taskId), "handoff.md");
3239
3310
  let content;
3240
3311
  try {
3241
- content = fs7.readFileSync(handoffPath, "utf8");
3312
+ content = fs8.readFileSync(handoffPath, "utf8");
3242
3313
  } catch {
3243
3314
  return { files: [], malformed: [] };
3244
3315
  }
@@ -3265,7 +3336,7 @@ function parseAffectedFilesFromSpec(taskId) {
3265
3336
  const specPath = path7.join(taskDirFor(taskId), "spec.md");
3266
3337
  let content;
3267
3338
  try {
3268
- content = fs7.readFileSync(specPath, "utf8");
3339
+ content = fs8.readFileSync(specPath, "utf8");
3269
3340
  } catch {
3270
3341
  return { files: [], malformed: [] };
3271
3342
  }
@@ -3567,6 +3638,24 @@ function verifyHandoffAgainstDiffFromData(taskIds, inputs) {
3567
3638
  }
3568
3639
  return issues;
3569
3640
  }
3641
+ function buildAffectedFilesAllowlist(taskIds, options) {
3642
+ const paths = new Set(PIPELINE_TELEMETRY_FILES);
3643
+ const prefixes = [];
3644
+ for (const taskId of taskIds) {
3645
+ const parsed = parseAffectedFilesFromSpec(taskId);
3646
+ for (const filePath of parsed.files) {
3647
+ if (filePath.endsWith("/")) prefixes.push(filePath);
3648
+ else paths.add(filePath);
3649
+ }
3650
+ for (const malformed of parsed.malformed) {
3651
+ warn(`${taskId} spec.md Affected Files row malformed: ${malformed.reason}`);
3652
+ }
3653
+ }
3654
+ if (options.admitManagedDocs) {
3655
+ for (const doc of PIPELINE_MANAGED_DOCS) paths.add(doc);
3656
+ }
3657
+ return { paths, prefixes };
3658
+ }
3570
3659
  function verifyBaseDriftFromData(diffFiles, allowedPaths, taskIds, allowedPrefixes = []) {
3571
3660
  const drift = [];
3572
3661
  for (const filePath of diffFiles) {
@@ -3577,6 +3666,35 @@ function verifyBaseDriftFromData(diffFiles, allowedPaths, taskIds, allowedPrefix
3577
3666
  }
3578
3667
  return drift;
3579
3668
  }
3669
+ function classifyBaseDriftFilesFromData(driftFiles, taskChangedFiles) {
3670
+ const baseAdvanced = [];
3671
+ const taskChangedOutOfScope = [];
3672
+ for (const filePath of driftFiles) {
3673
+ if (taskChangedFiles.has(filePath)) taskChangedOutOfScope.push(filePath);
3674
+ else baseAdvanced.push(filePath);
3675
+ }
3676
+ return { baseAdvanced, taskChangedOutOfScope };
3677
+ }
3678
+ function buildBaseDriftAbortMessage(baseBranch, classification) {
3679
+ const sections = ["--pr aborted: base-drift detected."];
3680
+ if (classification.baseAdvanced.length > 0) sections.push(
3681
+ `The base branch advanced. These files changed only on origin/${baseBranch}:
3682
+ ` + classification.baseAdvanced.map((file) => ` ${file}`).join("\n") + `
3683
+ Merge or rebase the base branch into the task branch, then rerun:
3684
+ git fetch origin ${baseBranch} && git rebase origin/${baseBranch}
3685
+ (or: git merge origin/${baseBranch})`
3686
+ );
3687
+ if (classification.taskChangedOutOfScope.length > 0) sections.push(
3688
+ `These task-changed files are not in the spec's Affected Files:
3689
+ ` + classification.taskChangedOutOfScope.map((file) => ` ${file}`).join("\n") + `
3690
+ The allowlist covers task artifacts, pipeline telemetry (PIPELINE_TELEMETRY_FILES), and spec Affected Files entries, including directory entries.
3691
+ Add legitimate paths to the spec's '### Affected Files' table (both sides of a rename), then rerun. If a sibling pipeline or stray task-branch commit introduced a file, remove it with:
3692
+ git checkout origin/${baseBranch} -- <path> && git commit -m 'revert drift on <path>'
3693
+ or: git revert <sha>`
3694
+ );
3695
+ sections.push(`Bypass with --force if you've verified the drift is intentional.`);
3696
+ return sections.join("\n\n");
3697
+ }
3580
3698
  function parseDiffNameStatus(stdout) {
3581
3699
  const diffFiles = [];
3582
3700
  const renamePairs = [];
@@ -3608,7 +3726,7 @@ function verifyHandoffAgainstDiff(taskIds, baseRef) {
3608
3726
  for (const taskId of taskIds) {
3609
3727
  let content;
3610
3728
  try {
3611
- content = fs7.readFileSync(path7.join(taskDirFor(taskId), "handoff.md"), "utf8");
3729
+ content = fs8.readFileSync(path7.join(taskDirFor(taskId), "handoff.md"), "utf8");
3612
3730
  } catch {
3613
3731
  continue;
3614
3732
  }
@@ -3640,31 +3758,18 @@ function verifyBaseDrift(taskIds, baseBranch, cwd) {
3640
3758
  if (!driftResult.ok) {
3641
3759
  return { drift: [], fetchFailed: false, diffFailed: true, diffError: driftResult.stderr };
3642
3760
  }
3643
- const allowedPaths = new Set(PIPELINE_TELEMETRY_FILES);
3644
- const allowedPrefixes = [];
3761
+ let admitManagedDocs = false;
3645
3762
  for (const taskId of taskIds) {
3646
- const parsed = parseAffectedFilesFromSpec(taskId);
3647
- for (const filePath of parsed.files) {
3648
- if (filePath.endsWith("/")) {
3649
- allowedPrefixes.push(filePath);
3650
- } else {
3651
- allowedPaths.add(filePath);
3652
- }
3653
- }
3654
- for (const malformed of parsed.malformed) {
3655
- warn(`${taskId} spec.md Affected Files row malformed: ${malformed.reason}`);
3656
- }
3657
3763
  try {
3658
3764
  if (readStatus(taskId).phases.qa?.status === "done") {
3659
- for (const doc of PIPELINE_MANAGED_DOCS) {
3660
- allowedPaths.add(doc);
3661
- }
3765
+ admitManagedDocs = true;
3662
3766
  }
3663
3767
  } catch {
3664
3768
  }
3665
3769
  }
3770
+ const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs });
3666
3771
  return {
3667
- drift: verifyBaseDriftFromData(driftResult.files, allowedPaths, taskIds, allowedPrefixes),
3772
+ drift: verifyBaseDriftFromData(driftResult.files, allowlist.paths, taskIds, allowlist.prefixes),
3668
3773
  fetchFailed: false,
3669
3774
  diffFailed: false
3670
3775
  };
@@ -3682,7 +3787,7 @@ function verifyBaseDivergenceFromData(commits) {
3682
3787
  function verifyBaseDivergence(baseBranch, cwd) {
3683
3788
  const fetchResult = gitSafeAt(cwd, "fetch", "origin", baseBranch);
3684
3789
  if (!fetchResult.ok) {
3685
- if (!fs7.existsSync(cwd)) {
3790
+ if (!fs8.existsSync(cwd)) {
3686
3791
  return { commits: [], ok: false, stderr: fetchResult.stderr, fetchFailed: false };
3687
3792
  }
3688
3793
  warn(
@@ -3766,11 +3871,11 @@ function buildSharedDocAbortMessage(abortedFiles) {
3766
3871
  }
3767
3872
 
3768
3873
  // src/orchestrator/prompts/index.ts
3769
- import fs11 from "fs";
3874
+ import fs12 from "fs";
3770
3875
  import path11 from "path";
3771
3876
 
3772
3877
  // src/orchestrator/context.ts
3773
- import fs8 from "fs";
3878
+ import fs9 from "fs";
3774
3879
  import path8 from "path";
3775
3880
  function extractAffectedFiles(taskId) {
3776
3881
  try {
@@ -3784,7 +3889,7 @@ function isSafeRepoPath(file) {
3784
3889
  const resolved = path8.resolve(REPO_ROOT, file);
3785
3890
  if (!resolved.startsWith(REPO_ROOT + path8.sep)) return false;
3786
3891
  try {
3787
- const real = fs8.realpathSync(resolved);
3892
+ const real = fs9.realpathSync(resolved);
3788
3893
  if (!real.startsWith(REPO_ROOT + path8.sep)) return false;
3789
3894
  } catch {
3790
3895
  }
@@ -3798,7 +3903,7 @@ function buildContextBlock(taskIds) {
3798
3903
  if (!isSafeRepoPath(file)) continue;
3799
3904
  const filePath = path8.join(REPO_ROOT, file);
3800
3905
  try {
3801
- allFiles.set(file, fs8.readFileSync(filePath, "utf8"));
3906
+ allFiles.set(file, fs9.readFileSync(filePath, "utf8"));
3802
3907
  } catch {
3803
3908
  }
3804
3909
  }
@@ -3829,7 +3934,7 @@ ${content}
3829
3934
  function buildKnownPitfalls(taskIds) {
3830
3935
  const patternsPath = process.env.CANON_PATTERNS_MD_PATH ?? path8.join(getActiveCwd(taskIds), "docs/patterns.md");
3831
3936
  try {
3832
- const content = fs8.readFileSync(patternsPath, "utf8");
3937
+ const content = fs9.readFileSync(patternsPath, "utf8");
3833
3938
  const match = content.match(/## Known Pitfalls\n\n([\s\S]*?)(?:\n## |\n---|\n# |$)/);
3834
3939
  if (!match) return "";
3835
3940
  return `
@@ -3846,7 +3951,7 @@ function buildKnownRisks(taskIds) {
3846
3951
  const riskBlocks = taskIds.map((taskId) => {
3847
3952
  const specPath = path8.join(taskDirFor(taskId), "spec.md");
3848
3953
  try {
3849
- const content = fs8.readFileSync(specPath, "utf8");
3954
+ const content = fs9.readFileSync(specPath, "utf8");
3850
3955
  const match = content.match(/## Known Risks\n\n([\s\S]*?)(?:\n## |\n# |$)/);
3851
3956
  if (!match) return "";
3852
3957
  const risks = match[1].trim();
@@ -3873,7 +3978,7 @@ function summarizePreloadStatus(taskIds) {
3873
3978
  if (!isSafeRepoPath(file)) continue;
3874
3979
  const filePath = path8.join(REPO_ROOT, file);
3875
3980
  try {
3876
- files.set(file, fs8.statSync(filePath).size);
3981
+ files.set(file, fs9.statSync(filePath).size);
3877
3982
  } catch {
3878
3983
  files.set(file, 0);
3879
3984
  }
@@ -3890,7 +3995,7 @@ function summarizePreloadStatus(taskIds) {
3890
3995
  function extractValidationChecks(taskId) {
3891
3996
  const specPath = path8.join(taskDirFor(taskId), "spec.md");
3892
3997
  try {
3893
- const content = fs8.readFileSync(specPath, "utf8");
3998
+ const content = fs9.readFileSync(specPath, "utf8");
3894
3999
  const section = content.match(/## Validation Required\n\n([\s\S]*?)(?:\n## |\n# |$)/);
3895
4000
  if (!section) return [];
3896
4001
  const checks = [];
@@ -3906,7 +4011,7 @@ function extractValidationChecks(taskId) {
3906
4011
  function extractAcSummary(taskId) {
3907
4012
  const specPath = path8.join(taskDirFor(taskId), "spec.md");
3908
4013
  try {
3909
- const content = fs8.readFileSync(specPath, "utf8");
4014
+ const content = fs9.readFileSync(specPath, "utf8");
3910
4015
  const lines = [];
3911
4016
  for (const line of content.split("\n")) {
3912
4017
  const match = line.match(/^-\s+\[[ x]\]\s+(AC-[\w.-]+):\s+(.+)$/);
@@ -3996,11 +4101,11 @@ ${acSection}`;
3996
4101
  }
3997
4102
 
3998
4103
  // src/orchestrator/review-archive.ts
3999
- import fs10 from "fs";
4104
+ import fs11 from "fs";
4000
4105
  import path10 from "path";
4001
4106
 
4002
4107
  // src/task/templates.ts
4003
- import fs9 from "fs";
4108
+ import fs10 from "fs";
4004
4109
  import path9 from "path";
4005
4110
  function tasksRoot() {
4006
4111
  return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
@@ -4013,17 +4118,17 @@ function taskTemplateOverrideRoot() {
4013
4118
  }
4014
4119
  function resolveTaskTemplateSource(basename) {
4015
4120
  const override = path9.join(taskTemplateOverrideRoot(), basename);
4016
- if (fs9.existsSync(override)) return override;
4121
+ if (fs10.existsSync(override)) return override;
4017
4122
  const managed = path9.join(templatesRoot(), basename);
4018
- return fs9.existsSync(managed) ? managed : null;
4123
+ return fs10.existsSync(managed) ? managed : null;
4019
4124
  }
4020
4125
  function renderTaskTemplate(source, taskId, title) {
4021
- return fs9.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
4126
+ return fs10.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
4022
4127
  }
4023
4128
  function scaffoldTaskArtifact(taskDir, basename, taskId, title) {
4024
4129
  const source = resolveTaskTemplateSource(basename);
4025
4130
  if (!source) return null;
4026
- fs9.writeFileSync(path9.join(taskDir, basename), renderTaskTemplate(source, taskId, title), "utf8");
4131
+ fs10.writeFileSync(path9.join(taskDir, basename), renderTaskTemplate(source, taskId, title), "utf8");
4027
4132
  return source;
4028
4133
  }
4029
4134
  function isPristineTaskArtifact(content, basename, taskId, title) {
@@ -4039,10 +4144,79 @@ function isPristineTaskArtifact(content, basename, taskId, title) {
4039
4144
 
4040
4145
  // src/orchestrator/review-archive.ts
4041
4146
  var REVIEW_ARCHIVE_PREFIX = "review-prior-";
4147
+ var COLD_CODEX_ARCHIVE_PREFIX = "review-cold-codex-run-";
4148
+ var COLD_CODEX_ARCHIVE_RE = /^review-cold-codex-run-(\d+)\.md$/;
4149
+ function writeColdCodexArchive(taskDir, header, findings) {
4150
+ let newest = 0;
4151
+ for (const name2 of fs11.readdirSync(taskDir)) {
4152
+ const match = COLD_CODEX_ARCHIVE_RE.exec(name2);
4153
+ if (match) newest = Math.max(newest, Number(match[1]));
4154
+ }
4155
+ const name = `${COLD_CODEX_ARCHIVE_PREFIX}${newest + 1}.md`;
4156
+ const reason = header.reason.replaceAll('"', "'");
4157
+ fs11.writeFileSync(
4158
+ path10.join(taskDir, name),
4159
+ `<!-- round=${header.round} reviewed_sha=${header.reviewedSha} scope=${header.scope} base=${header.base} reason="${reason}" -->
4160
+
4161
+ ${findings}`,
4162
+ "utf8"
4163
+ );
4164
+ return name;
4165
+ }
4166
+ function parseColdCodexArchiveHeader(content) {
4167
+ const match = /^<!-- round=(\d+) reviewed_sha=([a-f\d]{40,64}) scope=(full|delta) base=(\S+) reason="([^"\n]*)" -->\r?\n\r?\n/.exec(content);
4168
+ if (!match) return null;
4169
+ return {
4170
+ round: Number(match[1]),
4171
+ reviewedSha: match[2],
4172
+ scope: match[3],
4173
+ base: match[4],
4174
+ reason: match[5]
4175
+ };
4176
+ }
4177
+ function findColdCodexArchiveForRound(taskDir, round) {
4178
+ let names;
4179
+ try {
4180
+ names = fs11.readdirSync(taskDir);
4181
+ } catch {
4182
+ return null;
4183
+ }
4184
+ const numbered = names.flatMap((name) => {
4185
+ const match = COLD_CODEX_ARCHIVE_RE.exec(name);
4186
+ return match ? [{ name, number: Number(match[1]) }] : [];
4187
+ }).sort((a, b) => b.number - a.number);
4188
+ for (const { name } of numbered) {
4189
+ let content;
4190
+ try {
4191
+ content = fs11.readFileSync(path10.join(taskDir, name), "utf8");
4192
+ } catch {
4193
+ continue;
4194
+ }
4195
+ const header = parseColdCodexArchiveHeader(content);
4196
+ if (header?.round === round) return header;
4197
+ }
4198
+ return null;
4199
+ }
4200
+ function hasMalformedColdCodexArchive(taskDir) {
4201
+ let names;
4202
+ try {
4203
+ names = fs11.readdirSync(taskDir);
4204
+ } catch {
4205
+ return false;
4206
+ }
4207
+ return names.some((name) => {
4208
+ if (!COLD_CODEX_ARCHIVE_RE.test(name)) return false;
4209
+ try {
4210
+ return parseColdCodexArchiveHeader(fs11.readFileSync(path10.join(taskDir, name), "utf8")) === null;
4211
+ } catch {
4212
+ return true;
4213
+ }
4214
+ });
4215
+ }
4042
4216
  var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
4043
4217
  function newestReviewArchiveNumber(taskDir) {
4044
4218
  let newest = 0;
4045
- for (const name of fs10.readdirSync(taskDir)) {
4219
+ for (const name of fs11.readdirSync(taskDir)) {
4046
4220
  const match = REVIEW_ARCHIVE_RE.exec(name);
4047
4221
  if (match) newest = Math.max(newest, Number(match[1]));
4048
4222
  }
@@ -4054,21 +4228,21 @@ function findNewestReviewArchive(taskDir) {
4054
4228
  }
4055
4229
  function archivePriorReview(taskDir, options = {}) {
4056
4230
  const reviewPath = path10.join(taskDir, "review.md");
4057
- if (!fs10.existsSync(reviewPath)) return null;
4231
+ if (!fs11.existsSync(reviewPath)) return null;
4058
4232
  if (options.skipUnfilledTemplate) {
4059
- const content = fs10.readFileSync(reviewPath, "utf8");
4233
+ const content = fs11.readFileSync(reviewPath, "utf8");
4060
4234
  if (isTemplateUnfilled(content)) return null;
4061
4235
  if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
4062
4236
  return null;
4063
4237
  }
4064
4238
  }
4065
4239
  const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
4066
- fs10.renameSync(reviewPath, path10.join(taskDir, archiveName));
4240
+ fs11.renameSync(reviewPath, path10.join(taskDir, archiveName));
4067
4241
  return archiveName;
4068
4242
  }
4069
4243
  function rescaffoldReview(taskDir, identity) {
4070
4244
  try {
4071
- if (fs10.existsSync(path10.join(taskDir, "review.md"))) return { outcome: "exists" };
4245
+ if (fs11.existsSync(path10.join(taskDir, "review.md"))) return { outcome: "exists" };
4072
4246
  const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
4073
4247
  return source ? { outcome: "written", source } : { outcome: "no-template" };
4074
4248
  } catch (error) {
@@ -4542,10 +4716,10 @@ function renderTemplate(template, view) {
4542
4716
  }
4543
4717
 
4544
4718
  // src/orchestrator/prompts/templates/code-review-foreman.md
4545
- 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";
4719
+ 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}}. All three lenses run again this round. 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**Scope:** {{{scopeWord}}} \u2014 base `{{{scopeBase}}}` \u2014 reason: {{{scopeReason}}}\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{{#isDeltaScope}}\n\n{{#hasDeltaDiff}}\nDelta diff (`{{{deltaBase}}}..HEAD`) \u2014 give this delta to the cold-Claude lens:\n\n```diff\n{{{deltaDiffContent}}}\n```\n{{#deltaDiffTruncated}}\n> Delta diff truncated at 50 000 bytes. Give the cold-Claude lens the visible delta first; for the remainder, direct it to `git diff {{{deltaBase}}}..HEAD`.\n{{/deltaDiffTruncated}}\n{{/hasDeltaDiff}}\n{{^hasDeltaDiff}}\nThe delta diff could not be injected. Retrieve it with `git diff {{{deltaBase}}}..HEAD` and give it to the cold-Claude lens before it reviews.\n{{/hasDeltaDiff}}\n{{/isDeltaScope}}\n\n## Injected Cold-Codex Findings\n\n{{#hasColdCodexFindings}}\nThe orchestrator ran `codex review` 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{{#isDeltaScope}}\nFor this round, cold-Codex reviewed only `{{{deltaBase}}}..HEAD`.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\nFor this round, cold-Codex reviewed the full task diff against `{{{baseBranch}}}`.\n{{/isDeltaScope}}\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{{#hasOutOfScopeFiles}}\n## Full-Send Scope Judgment\n\nThe task changed these files outside the spec's `### Affected Files` table:\n\n{{{outOfScopeFilesList}}}\n\nJudge each file against the owning task's ACs and record its outcome and reason in `review.md`:\n\n1. **Appropriate scope expansion:** If needed to meet the ACs and consistent with the spec's intent, append an `## Amendment (full-send scope)` section to the owning task's `spec.md` (use exactly that heading; a bare `## Amendment` is reserved for human reroute amendments). Include an exact `### Affected Files` heading and table row naming the file, plus a one-line reason that calls it a full-send scope expansion.\n2. **Spec miss:** If the spec should have listed the file, append the same `## Amendment (full-send scope)` section and exact `### Affected Files` table row, with a one-line reason that calls it a spec miss.\n3. **Should not have been changed:** Do not amend the spec. Write a finding requiring the file to be reverted or removed, and set the verdict to `changes_requested`.\n\nFor a bundle, amend the member spec whose ACs the file serves. The orchestrator checks after code review completes: every listed file must be in a member's Affected Files or a `changes_requested` verdict must route it back to implement. The legacy `needs_re_review` verdict routes the same way. A `spec_gap` verdict blocks for human triage and names any files still outside Affected Files; blessing that verdict does not amend the spec. An approved review with unjudged files auto-blocks.\n\n{{/hasOutOfScopeFiles}}\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{{#isDeltaScope}}\n- Retrieve the full task diff with `git diff {{{baseBranch}}}...HEAD` and give it that full diff, not the delta, plus `spec.md`, `handoff.md`, and prior `review.md`.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\n- Give it the full diff, `spec.md`, `handoff.md`, and prior `review.md` if this is a re-review.\n{{/isDeltaScope}}\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{{#isDeltaScope}}\n- This round is delta-scoped. Give it the delta (`{{{deltaBase}}}..HEAD`) and the delta base ref only.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\n- Give it the full diff and base ref only.\n{{/isDeltaScope}}\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- Separately, its sibling-site sweep may search other code files for guards, checks, and invariants that need the same treatment. Keep the search spec-blind.\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\nEvery round \u2014 the Round 1 body and each `## Round N` section \u2014 includes one scope line near the top: **Scope:** {{{scopeWord}}} \u2014 base `{{{scopeBase}}}` \u2014 reason: {{{scopeReason}}}. The numbered cold-Codex archive header is the machine-readable record.\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";
4546
4720
 
4547
4721
  // src/orchestrator/prompts/templates/implement.md
4548
- 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, do not make that edit. Record the gap in `handoff.md` under *Blockers* \u2014 the handoff is how it reaches a human \u2014 then finish the remaining in-scope work, the handoff, and the phase command. 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, do not implement a fix on a premise you could not reproduce \u2014 record a `[wrong-premise]` Blocker in handoff.md instead, then finish the remaining in-scope work, the handoff, and the phase command. 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';
4722
+ 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, do not make that edit. Record the gap in `handoff.md` under *Blockers* \u2014 the handoff is how it reaches a human \u2014 then finish the remaining in-scope work, the handoff, and the phase command. Do not silently expand scope. New helpers, fixtures, and test files introduced during implementation count against this cap too.\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, do not implement a fix on a premise you could not reproduce \u2014 record a `[wrong-premise]` Blocker in handoff.md instead, then finish the remaining in-scope work, the handoff, and the phase command. 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';
4549
4723
 
4550
4724
  // src/orchestrator/prompts/templates/implement-reroute.md
4551
4725
  var implement_reroute_default = "A human wrote a spec amendment and rerouted {{taskScope}} for {{projectName}}.\n\n{{{stateHeader}}}\n{{{roundBanner}}}{{{preamble}}}\n\n{{#startup}}{{{startup}}}\n{{/startup}}{{{risksBlock}}}{{{pitfallsBlock}}}{{{contextBlock}}}\n{{{affectedFilesBlock}}}\nTasks with amended specs:\n{{{taskLines}}}\n\n{{{groundingRule}}}\n\n**How to approach this:**\n0. If a task's line above marks it EXEMPT, skip steps 1-2 for that task entirely \u2014 its spec has NO Amendment section and its plan has NO Reroute Plan section, by design. For exempt tasks, follow the task-specific line above: approved siblings only need shared-behavior re-verification, while siblings with prior review findings must still address those findings.\n1. For each task above, read `tasks/<id>/spec.md` from your current working directory (the worktree). REPO_ROOT's copy is the pre-implement scaffold and does NOT contain operator amendments. Locate the exact heading named in its entry \u2014 `## Amendment` for round 1, or `## Amendment Round N` for round 2+. Each task carries its own reroute round (bundles may mix rounds), so use the heading specified in that task's line, not a bundle-wide assumption. Treat that section's content as the new requirements; ignore prior-round sections when implementing this one.\n2. Check `tasks/<id>/plan.md` for `## Reroute Plan` (round 1) or `## Reroute Plan Round N` (N = that task's reroute round). If present, use that section as the delta guide. If absent (fast-tier reroute with no conversational reroute plan), read the base plan for orientation.\n3. Read tasks/<id>/handoff.md to understand what you previously shipped. Do NOT assume the handoff covers the amendment \u2014 it was written before the amendment existed.\n4. Identify the delta: which ACs are new, which changed, which were already addressed by the previous implementation.\n5. Implement the delta. Previously-correct work stays; only change what the amendment requires. If the amendment conflicts with a prior AC, the amendment wins.\n6. Re-run ALL applicable validation checks (lint, type-check, test, build, e2e as applicable per the spec's Validation Required). Required checks must be recorded as Pass or Fail; do not mark a required check N/A.\n7. **Rewrite handoff.md** to reflect the complete current state of the implementation \u2014 including the round-1 work that still applies plus the new amendment work. The reviewer reads handoff.md as the single source of truth, not your prior session's context.\n\n**Spec ACs are binding** \u2014 including both original ACs and amendment ACs. If you think an amendment AC is infeasible as written, document it under Blockers in handoff.md. Do not silently drop any AC.\n\n**Bug/flake-fix red-first checkpoint** \u2014 if the amendment adds or modifies a red-first regression-test AC: write and run the test against the task's current state **before** making this round's fix edits, and confirm it fails *for the reason the spec states*; then apply the fix and confirm it passes. Report the red run in handoff.md. If it cannot be made to fail for that reason, record a `[wrong-premise]` Blocker instead of implementing on an unconfirmed premise. If the amendment instead relies on the environment-bound-and-impractical escape, run its named deterministic alternative if it is executable in your sandbox and report the outcome in handoff.md; if it is not executable (e.g. a documented manual repro), state that instead \u2014 never report an outcome for a run that did not happen.\n\nAppend to tasks/<id>/notes.md for any surprising behavior found while re-reading the codebase (prefix: `[implement-reroute]`).\n\nWhen done, run:\n{{{phaseCommands}}}\n";
@@ -4882,7 +5056,7 @@ function bundleHasRealPriorReview(taskIds) {
4882
5056
  return taskIds.every((taskId) => {
4883
5057
  const reviewPath = path11.join(taskDirFor(taskId), "review.md");
4884
5058
  try {
4885
- const content = fs11.readFileSync(reviewPath, "utf8");
5059
+ const content = fs12.readFileSync(reviewPath, "utf8");
4886
5060
  const hasH2 = /^## Stage 1\b/m.test(content);
4887
5061
  const hasNested = /^## Round \d+\b/m.test(content) && /^### Stage 1\b/m.test(content);
4888
5062
  return (hasH2 || hasNested) && !content.includes("[TASK-ID]");
@@ -4891,20 +5065,24 @@ function bundleHasRealPriorReview(taskIds) {
4891
5065
  }
4892
5066
  });
4893
5067
  }
4894
- function promptCodeReview(state, baseBranch, scopedDiff = null, coldCodexFindings = null) {
4895
- const { tasks } = state;
5068
+ function resolveCodeReviewRound(tasks) {
4896
5069
  const rawMaxIter = tasks.reduce((max, t) => Math.max(max, t.iterations), 0);
4897
5070
  const maxIter = bundleHasRealPriorReview(tasks.map((t) => t.taskId)) ? rawMaxIter : 0;
5071
+ return { isRound1: maxIter === 0, roundN: maxIter + 1, maxIter };
5072
+ }
5073
+ function promptCodeReview(state, baseBranch, scopedDiff = null, coldCodexFindings = null, scopeInfo = null, outOfScopeFiles = []) {
5074
+ const { tasks } = state;
5075
+ const { maxIter, isRound1, roundN } = resolveCodeReviewRound(tasks);
4898
5076
  const resolvedBaseBranch = baseBranch ?? getBaseBranch(tasks.map((t) => t.taskId));
4899
- const hasDiff = scopedDiff !== null;
4900
- const isRound1 = maxIter === 0;
4901
- const roundN = maxIter + 1;
5077
+ const isDeltaScope = scopeInfo?.scope === "delta";
5078
+ const effectiveScopedDiff = isDeltaScope ? null : scopedDiff;
5079
+ const hasDiff = effectiveScopedDiff !== null;
4902
5080
  const priorIteration = maxIter;
4903
5081
  const diffView = hasDiff ? {
4904
5082
  hasDiff,
4905
5083
  baseBranch: resolvedBaseBranch,
4906
- diffContent: scopedDiff.diff,
4907
- diffTruncated: scopedDiff.truncated
5084
+ diffContent: effectiveScopedDiff.diff,
5085
+ diffTruncated: effectiveScopedDiff.truncated
4908
5086
  } : {
4909
5087
  hasDiff,
4910
5088
  baseBranch: resolvedBaseBranch,
@@ -4929,8 +5107,18 @@ function promptCodeReview(state, baseBranch, scopedDiff = null, coldCodexFinding
4929
5107
  maxIter,
4930
5108
  tightenLine,
4931
5109
  ...diffView,
5110
+ isDeltaScope,
5111
+ scopeWord: isDeltaScope ? "Delta" : "Full",
5112
+ scopeBase: scopeInfo?.base ?? resolvedBaseBranch,
5113
+ scopeReason: scopeInfo?.reason === "delta" ? "small fix within the files already under review" : scopeInfo?.reason ?? (isRound1 ? "Round 1 (initial review)" : "full review"),
5114
+ deltaBase: scopeInfo?.base ?? "",
5115
+ deltaDiffContent: scopeInfo?.deltaDiff?.diff ?? "",
5116
+ hasDeltaDiff: scopeInfo?.deltaDiff !== null && scopeInfo?.deltaDiff !== void 0,
5117
+ deltaDiffTruncated: scopeInfo?.deltaDiff?.truncated ?? false,
4932
5118
  coldCodexFindings: coldCodexFindings ?? "",
4933
5119
  hasColdCodexFindings: coldCodexFindings !== null,
5120
+ hasOutOfScopeFiles: outOfScopeFiles.length > 0,
5121
+ outOfScopeFilesList: outOfScopeFiles.map((file) => `- \`${file}\``).join("\n"),
4934
5122
  phaseCommands: phaseCommands(tasks.map((t) => t.taskId), "code_review", "done", "<verdict>")
4935
5123
  });
4936
5124
  }
@@ -4953,19 +5141,19 @@ function promptQa(state, prTemplate) {
4953
5141
 
4954
5142
  // src/task/index.ts
4955
5143
  import { spawnSync as spawnSync5 } from "child_process";
4956
- import fs14 from "fs";
5144
+ import fs15 from "fs";
4957
5145
  import path14 from "path";
4958
5146
 
4959
5147
  // src/orchestrator/canon-snapshot.ts
4960
5148
  import { spawnSync as spawnSync4 } from "child_process";
4961
- import fs12 from "fs";
5149
+ import fs13 from "fs";
4962
5150
  import path12 from "path";
4963
5151
  var CANON_UPSTREAM_REPO = "tstraub89/canon-ai";
4964
5152
  function isInstalledSourcePath(sourcePath) {
4965
5153
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
4966
5154
  }
4967
5155
  function resolveCanonVersion(explicit) {
4968
- return explicit ?? "3.3.0" ?? "dev";
5156
+ return explicit ?? "3.4.0" ?? "dev";
4969
5157
  }
4970
5158
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
4971
5159
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
@@ -5044,14 +5232,14 @@ function applyCanonSnapshot(status, canon) {
5044
5232
  return next;
5045
5233
  }
5046
5234
  function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
5047
- const status = JSON.parse(fs12.readFileSync(statusFilePath, "utf8"));
5235
+ const status = JSON.parse(fs13.readFileSync(statusFilePath, "utf8"));
5048
5236
  const canon = captureCanonSnapshot(REPO_ROOT, options);
5049
5237
  const next = applyCanonSnapshot(status, canon);
5050
5238
  const serialized = `${JSON.stringify(next, null, 2)}
5051
5239
  `;
5052
- const current = fs12.readFileSync(statusFilePath, "utf8");
5240
+ const current = fs13.readFileSync(statusFilePath, "utf8");
5053
5241
  if (current !== serialized) {
5054
- fs12.writeFileSync(statusFilePath, serialized, "utf8");
5242
+ fs13.writeFileSync(statusFilePath, serialized, "utf8");
5055
5243
  }
5056
5244
  return canon;
5057
5245
  }
@@ -5060,7 +5248,7 @@ function refreshCanonSnapshotsAtPaths(statusFilePaths, options = {}) {
5060
5248
  }
5061
5249
 
5062
5250
  // src/orchestrator/quality-log.ts
5063
- import fs13 from "fs";
5251
+ import fs14 from "fs";
5064
5252
  import path13 from "path";
5065
5253
  var CANON_LOG_HEADERS = [
5066
5254
  "Date",
@@ -5255,11 +5443,11 @@ function renderRowLine(headerCells, row) {
5255
5443
  function writeFileAtomic(filePath, content) {
5256
5444
  const tempPath = `${filePath}.tmp`;
5257
5445
  try {
5258
- fs13.writeFileSync(tempPath, content, "utf8");
5259
- fs13.renameSync(tempPath, filePath);
5446
+ fs14.writeFileSync(tempPath, content, "utf8");
5447
+ fs14.renameSync(tempPath, filePath);
5260
5448
  } finally {
5261
5449
  try {
5262
- fs13.unlinkSync(tempPath);
5450
+ fs14.unlinkSync(tempPath);
5263
5451
  } catch {
5264
5452
  }
5265
5453
  }
@@ -5268,7 +5456,7 @@ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
5268
5456
  try {
5269
5457
  let content;
5270
5458
  try {
5271
- content = fs13.readFileSync(logFilePath, "utf8");
5459
+ content = fs14.readFileSync(logFilePath, "utf8");
5272
5460
  } catch (error) {
5273
5461
  if (error.code === "ENOENT") {
5274
5462
  content = STANDARD_QUALITY_LOG_SKELETON;
@@ -5327,7 +5515,7 @@ function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
5327
5515
  try {
5328
5516
  let doneContent = "";
5329
5517
  try {
5330
- doneContent = fs13.readFileSync(donePath, "utf8");
5518
+ doneContent = fs14.readFileSync(donePath, "utf8");
5331
5519
  } catch {
5332
5520
  }
5333
5521
  upsertQualityLogRow(
@@ -5381,7 +5569,7 @@ function taskRootForGate(cwd) {
5381
5569
  }
5382
5570
  function readJsonFile(filePath) {
5383
5571
  try {
5384
- return JSON.parse(fs14.readFileSync(filePath, "utf8"));
5572
+ return JSON.parse(fs15.readFileSync(filePath, "utf8"));
5385
5573
  } catch (error) {
5386
5574
  const message = error instanceof Error ? error.message : String(error);
5387
5575
  throw new Error(`Error: failed to read ${filePath}: ${message}`);
@@ -5389,9 +5577,9 @@ function readJsonFile(filePath) {
5389
5577
  }
5390
5578
  function writeJsonAtomic(filePath, data) {
5391
5579
  const tmpFile = `${filePath}.tmp`;
5392
- fs14.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
5580
+ fs15.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
5393
5581
  `, "utf8");
5394
- fs14.renameSync(tmpFile, filePath);
5582
+ fs15.renameSync(tmpFile, filePath);
5395
5583
  }
5396
5584
  function writeStatusAtomic(filePath, status) {
5397
5585
  status.status = deriveTopLevelStatus(status);
@@ -5464,7 +5652,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
5464
5652
  assertValidVerdict(phaseArg, verdictArg);
5465
5653
  const taskCwd = resolveTaskCwd(id);
5466
5654
  const statusPath = taskStatusFileForCwd(taskCwd, id);
5467
- if (!fs14.existsSync(statusPath)) {
5655
+ if (!fs15.existsSync(statusPath)) {
5468
5656
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
5469
5657
  }
5470
5658
  const status = readJsonFile(statusPath);
@@ -5526,7 +5714,7 @@ function taskPhasePreflightRejected(id, phaseArg) {
5526
5714
  }
5527
5715
  const taskCwd = resolveTaskCwd(id);
5528
5716
  const statusPath = taskStatusFileForCwd(taskCwd, id);
5529
- if (!fs14.existsSync(statusPath)) {
5717
+ if (!fs15.existsSync(statusPath)) {
5530
5718
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
5531
5719
  }
5532
5720
  const status = readJsonFile(statusPath);
@@ -5549,6 +5737,14 @@ function taskPhasePreflightRejected(id, phaseArg) {
5549
5737
  }
5550
5738
 
5551
5739
  // src/orchestrator/phases/code-review.ts
5740
+ function findUnjudgedFullSendFilesFromData(changedFiles, allowlist, taskIds, verdicts) {
5741
+ if (verdicts.some((verdict) => verdict === "changes_requested" || verdict === "needs_re_review" || verdict === "spec_gap")) return [];
5742
+ return verifyBaseDriftFromData(changedFiles, allowlist.paths, taskIds, allowlist.prefixes);
5743
+ }
5744
+ function fullSendScopeBlockReason(taskIds, files, recordedVerdicts) {
5745
+ const verdictNote = recordedVerdicts.length > 0 ? ` Recorded code-review verdict(s): ${recordedVerdicts.join(", ")}; inspect the review before resuming.` : "";
5746
+ return `Full-send code review left out-of-scope files unjudged: ${files.join(", ")}. Add them to a task spec's ### Affected Files table, then re-run \`canon run ${taskIds.join(" ")}\`; or reroute with a note telling the implementer to remove them.${verdictNote}`;
5747
+ }
5552
5748
  var defaultDeps = {
5553
5749
  verifyBranch,
5554
5750
  getBaseBranch,
@@ -5556,6 +5752,15 @@ var defaultDeps = {
5556
5752
  getAffectedFiles,
5557
5753
  verifyHandoffAgainstDiff,
5558
5754
  getScopedDiff,
5755
+ getScopedDiffInRange,
5756
+ getPathsInRange,
5757
+ getDeltaLineStats,
5758
+ resolveCommit,
5759
+ isAncestorCommit,
5760
+ getEffectiveSize: getEffectiveSize2,
5761
+ findColdCodexArchiveForRound,
5762
+ hasMalformedColdCodexArchive,
5763
+ writeColdCodexArchive,
5559
5764
  getClaudeConfig,
5560
5765
  getMaxReviewLoops,
5561
5766
  getCodexConfig,
@@ -5684,7 +5889,7 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
5684
5889
  const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
5685
5890
  let existing = "";
5686
5891
  try {
5687
- existing = fs15.readFileSync(reviewPath, "utf8");
5892
+ existing = fs16.readFileSync(reviewPath, "utf8");
5688
5893
  } catch {
5689
5894
  }
5690
5895
  const hasH2Stage1 = /^## Stage 1\b/m.test(existing);
@@ -5700,7 +5905,7 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
5700
5905
  ${blockedBlock}` : `# Code Review: ${t.taskId}
5701
5906
 
5702
5907
  ${blockedBlock}`;
5703
- fs15.writeFileSync(reviewPath, reviewContent2, "utf8");
5908
+ fs16.writeFileSync(reviewPath, reviewContent2, "utf8");
5704
5909
  continue;
5705
5910
  }
5706
5911
  const currentPreflight = t.status.phases.code_review?.preflight_rejections_current_loop ?? 0;
@@ -5711,7 +5916,7 @@ ${blockedBlock}`;
5711
5916
  ---
5712
5917
 
5713
5918
  ${stub}` : stub;
5714
- fs15.writeFileSync(reviewPath, reviewContent, "utf8");
5919
+ fs16.writeFileSync(reviewPath, reviewContent, "utf8");
5715
5920
  }
5716
5921
  return true;
5717
5922
  }
@@ -5756,11 +5961,63 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
5756
5961
  }
5757
5962
  return { agent: "claude", sessionId: null, exitCode: 0 };
5758
5963
  }
5964
+ const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs: true });
5965
+ const outOfScopeFiles = verifyBaseDriftFromData(
5966
+ [...changedFiles],
5967
+ allowlist.paths,
5968
+ taskIds,
5969
+ allowlist.prefixes
5970
+ );
5971
+ const scopeFullSend = tasks.every((t) => t.status.full_send === true);
5972
+ if (outOfScopeFiles.length > 0 && !scopeFullSend) {
5973
+ const reason = `Code review pre-flight found files outside the spec's Affected Files for ${taskIds.join(", ")}:
5974
+ ` + outOfScopeFiles.map((file) => ` ${file}`).join("\n") + `
5975
+ Add them to the affected task spec's ### Affected Files table in the active checkout, then re-run \`canon run ${taskIds.join(" ")}\`; or reroute with a note telling the implementer to remove them.`;
5976
+ warn(reason);
5977
+ autoBlockPhase(taskIds, "code_review", codeReviewCheck.count, reason);
5978
+ process.exit(2);
5979
+ }
5980
+ const headSha = deps.resolveCommit("HEAD", activeCwd);
5981
+ if (!headSha) {
5982
+ setExitReason(`Cannot start code_review for task(s) ${taskIds.join(", ")}: HEAD does not resolve to a commit.`);
5983
+ process.exit(1);
5984
+ }
5759
5985
  info(`Phase: code_review (Claude${state.isBundle ? " bundle" : ""}, iteration ${maxIter + 1})`);
5760
5986
  for (const t of tasks) taskPhase(t.taskId, "code_review", "in_progress");
5987
+ const { isRound1, roundN } = resolveCodeReviewRound(tasks);
5988
+ const effectiveSize = deps.getEffectiveSize(tasks);
5989
+ const delicate = tasks.some((t) => t.status.delicate === true);
5990
+ const records = !isRound1 && effectiveSize !== "XL" ? tasks.map((t) => deps.findColdCodexArchiveForRound(taskDirFor(t.taskId), roundN - 1)) : [];
5991
+ const agreed = records.length === tasks.length && records.every((record) => record !== null && record.reviewedSha === records[0]?.reviewedSha);
5992
+ const previousRecordsDisagree = records.length === tasks.length && records.every((record) => record !== null) && !agreed;
5993
+ const previousArchiveMalformed = records.some((record, index) => record === null && deps.hasMalformedColdCodexArchive(taskDirFor(tasks[index].taskId)));
5994
+ const reviewedSha = agreed ? records[0]?.reviewedSha : void 0;
5995
+ const resolved = reviewedSha ? deps.resolveCommit(reviewedSha, activeCwd) : null;
5996
+ const prevRecord = reviewedSha ? {
5997
+ reviewedSha,
5998
+ exists: resolved !== null,
5999
+ isAncestor: resolved !== null && deps.isAncestorCommit(reviewedSha, headSha, activeCwd),
6000
+ equalsHead: resolved !== null && resolved === headSha
6001
+ } : null;
6002
+ const canProbe = prevRecord?.exists === true && prevRecord.isAncestor && !prevRecord.equalsHead;
6003
+ const scope = resolveCodeReviewScope({
6004
+ isRound1,
6005
+ effectiveSize,
6006
+ delicate,
6007
+ baseBranch,
6008
+ prevRecord,
6009
+ previousArchiveMalformed,
6010
+ previousRecordsDisagree,
6011
+ deltaPaths: canProbe ? deps.getPathsInRange(`${reviewedSha}..${headSha}`, activeCwd) : [],
6012
+ priorChangeSetPaths: canProbe ? deps.getPathsInRange(`${baseBranch}...${reviewedSha}`, activeCwd) : [],
6013
+ deltaFileStats: canProbe && reviewedSha ? deps.getDeltaLineStats(reviewedSha, activeCwd, headSha) : [],
6014
+ taskIds,
6015
+ telemetryFiles: PIPELINE_TELEMETRY_FILES
6016
+ });
6017
+ const deltaDiff = scope.scope === "delta" ? deps.getScopedDiffInRange(`${scope.base}..${headSha}`, activeCwd) : null;
5761
6018
  const coldCfg = deps.getCodexConfig("code_review", tasks);
5762
6019
  const coldReviewStartMs = Date.now();
5763
- const coldReview = await deps.runColdCodexReview(baseBranch, coldCfg.model, coldCfg.effort, activeCwd, {
6020
+ const coldReview = await deps.runColdCodexReview(scope.base, coldCfg.model, coldCfg.effort, activeCwd, {
5764
6021
  taskId: taskIds.join("+"),
5765
6022
  phase: "code_review",
5766
6023
  iteration: maxIter,
@@ -5774,17 +6031,26 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
5774
6031
  process.exit(1);
5775
6032
  }
5776
6033
  for (const t of tasks) {
5777
- fs15.writeFileSync(
6034
+ fs16.writeFileSync(
5778
6035
  path15.join(taskDirFor(t.taskId), "review-cold-codex.md"),
5779
6036
  coldReview.findings,
5780
6037
  "utf8"
5781
6038
  );
5782
6039
  }
6040
+ for (const t of tasks) {
6041
+ deps.writeColdCodexArchive(taskDirFor(t.taskId), {
6042
+ round: roundN,
6043
+ reviewedSha: headSha,
6044
+ scope: scope.scope,
6045
+ base: scope.base,
6046
+ reason: scope.reason
6047
+ }, coldReview.findings);
6048
+ }
5783
6049
  info(`\u2192 cold-codex review (${taskIds.join(", ")}): ${Math.round(coldReviewDurationMs / 1e3)}s`);
5784
6050
  const cfg = deps.getClaudeConfig("code_review", tasks);
5785
6051
  const reviewResumeId = maxIter > 0 ? resumeId : null;
5786
- const scopedDiff = deps.getScopedDiff(baseBranch, activeCwd);
5787
- const result = await deps.runClaude(promptCodeReview(state, baseBranch, scopedDiff, coldReview.findings), interactive, reviewResumeId, cfg.model, cfg.effort, cfg.budget, {
6052
+ const scopedDiff = scope.scope === "delta" ? null : deps.getScopedDiff(baseBranch, activeCwd);
6053
+ const result = await deps.runClaude(promptCodeReview(state, baseBranch, scopedDiff, coldReview.findings, { ...scope, deltaDiff }, outOfScopeFiles), interactive, reviewResumeId, cfg.model, cfg.effort, cfg.budget, {
5788
6054
  taskId: taskIds.join("+"),
5789
6055
  phase: "code_review",
5790
6056
  iteration: maxIter,
@@ -5794,7 +6060,7 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
5794
6060
  const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
5795
6061
  let reviewContent = null;
5796
6062
  try {
5797
- reviewContent = fs15.readFileSync(reviewPath, "utf8");
6063
+ reviewContent = fs16.readFileSync(reviewPath, "utf8");
5798
6064
  } catch {
5799
6065
  }
5800
6066
  if (isTemplateUnfilled(reviewContent)) {
@@ -5895,7 +6161,7 @@ async function runImplementPhase(state, interactive, resumeId, force = false) {
5895
6161
  }
5896
6162
 
5897
6163
  // src/orchestrator/phases/plan.ts
5898
- import fs16 from "fs";
6164
+ import fs17 from "fs";
5899
6165
  import path16 from "path";
5900
6166
  async function runPlanPhase(state, interactive) {
5901
6167
  const { tasks } = state;
@@ -5914,7 +6180,7 @@ async function runPlanPhase(state, interactive) {
5914
6180
  const planPath = path16.join(taskDirFor(t.taskId), "plan.md");
5915
6181
  let planContent = null;
5916
6182
  try {
5917
- planContent = fs16.readFileSync(planPath, "utf8");
6183
+ planContent = fs17.readFileSync(planPath, "utf8");
5918
6184
  } catch {
5919
6185
  }
5920
6186
  if (isTemplateUnfilled(planContent)) {
@@ -5926,7 +6192,7 @@ async function runPlanPhase(state, interactive) {
5926
6192
  }
5927
6193
 
5928
6194
  // src/orchestrator/phases/qa.ts
5929
- import fs17 from "fs";
6195
+ import fs18 from "fs";
5930
6196
  import path17 from "path";
5931
6197
  async function runQaPhase(state, interactive, resolvedPrTemplate) {
5932
6198
  const { tasks } = state;
@@ -5948,7 +6214,7 @@ async function runQaPhase(state, interactive, resolvedPrTemplate) {
5948
6214
  if (isDoneMdTemplate(donePath)) {
5949
6215
  const salvaged = extractDoneMdFromStdout(result.capturedStdout);
5950
6216
  if (salvaged) {
5951
- fs17.writeFileSync(donePath, salvaged);
6217
+ fs18.writeFileSync(donePath, salvaged);
5952
6218
  warn(`Salvaged tasks/${taskId}/done.md from captured stdout \u2014 QA sub-agent streamed content instead of using the Write tool.`);
5953
6219
  const phaseStatus = readStatus(taskId).phases.qa?.status ?? "pending";
5954
6220
  if (phaseStatus !== "done") {
@@ -6000,7 +6266,7 @@ async function runSpecPhase(state, interactive, resumeId) {
6000
6266
  }
6001
6267
 
6002
6268
  // src/orchestrator/phases/spec-review.ts
6003
- import fs18 from "fs";
6269
+ import fs19 from "fs";
6004
6270
  import path18 from "path";
6005
6271
  function autoBlockSpecReview(taskIds, iterationCount, reason) {
6006
6272
  autoBlockPhase(taskIds, "spec_review", iterationCount, reason);
@@ -6009,14 +6275,14 @@ function recordFastTierSpecApproval(taskId) {
6009
6275
  const artifactPath = path18.join(taskDirFor(taskId), "spec-review.md");
6010
6276
  let content;
6011
6277
  try {
6012
- content = fs18.readFileSync(artifactPath, "utf8");
6278
+ content = fs19.readFileSync(artifactPath, "utf8");
6013
6279
  } catch {
6014
6280
  return;
6015
6281
  }
6016
6282
  if (extractCheckedVerdict(content)) return;
6017
6283
  const checked = content.replace(/^- \[ \] (\*\*Approved\*\*)/m, "- [x] $1");
6018
6284
  const note = "\n> Fast tier: Codex spec review skipped \u2014 human conversational spec approval recorded by the orchestrator.\n";
6019
- fs18.writeFileSync(
6285
+ fs19.writeFileSync(
6020
6286
  artifactPath,
6021
6287
  (checked !== content ? checked : `${content}
6022
6288
  ## Verdict
@@ -6088,7 +6354,7 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
6088
6354
  const reviewPath = path18.join(resolveTaskCwd(t.taskId), "tasks", t.taskId, "spec-review.md");
6089
6355
  let reviewContent = null;
6090
6356
  try {
6091
- reviewContent = fs18.readFileSync(reviewPath, "utf8");
6357
+ reviewContent = fs19.readFileSync(reviewPath, "utf8");
6092
6358
  } catch {
6093
6359
  }
6094
6360
  if (isTemplateUnfilled(reviewContent)) {
@@ -6101,7 +6367,7 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
6101
6367
 
6102
6368
  // src/orchestrator/detach.ts
6103
6369
  import { spawn as spawn3 } from "child_process";
6104
- import fs19 from "fs";
6370
+ import fs20 from "fs";
6105
6371
  import path19 from "path";
6106
6372
  var DETACH_CHILD_FLAG = "CANON_DETACHED";
6107
6373
  var DETACH_DISABLE_FLAG = "CANON_NO_DETACH";
@@ -6138,7 +6404,7 @@ function detachAndExit(options) {
6138
6404
  }
6139
6405
  const primaryDir = options.resolveTaskDir(options.taskIds[0]);
6140
6406
  try {
6141
- fs19.mkdirSync(primaryDir, { recursive: true });
6407
+ fs20.mkdirSync(primaryDir, { recursive: true });
6142
6408
  } catch (error) {
6143
6409
  stderrWrite(`canon: cannot create task dir for log file: ${error.message}
6144
6410
  `);
@@ -6147,7 +6413,7 @@ function detachAndExit(options) {
6147
6413
  const logPath = path19.join(primaryDir, LOG_FILENAME);
6148
6414
  let logFd;
6149
6415
  try {
6150
- logFd = fs19.openSync(logPath, "a");
6416
+ logFd = fs20.openSync(logPath, "a");
6151
6417
  } catch (error) {
6152
6418
  stderrWrite(`canon: cannot open ${logPath}: ${error.message}
6153
6419
  `);
@@ -6160,7 +6426,7 @@ function detachAndExit(options) {
6160
6426
  env: { ...process.env, [DETACH_CHILD_FLAG]: "1" }
6161
6427
  });
6162
6428
  try {
6163
- fs19.closeSync(logFd);
6429
+ fs20.closeSync(logFd);
6164
6430
  } catch {
6165
6431
  }
6166
6432
  if (child.pid == null) {
@@ -6171,8 +6437,8 @@ function detachAndExit(options) {
6171
6437
  for (const taskId of options.taskIds) {
6172
6438
  try {
6173
6439
  const dir = options.resolveTaskDir(taskId);
6174
- fs19.mkdirSync(dir, { recursive: true });
6175
- fs19.writeFileSync(path19.join(dir, PID_FILENAME), `${child.pid}
6440
+ fs20.mkdirSync(dir, { recursive: true });
6441
+ fs20.writeFileSync(path19.join(dir, PID_FILENAME), `${child.pid}
6176
6442
  `, "utf8");
6177
6443
  } catch (error) {
6178
6444
  pidWriteFailures.push({
@@ -6228,7 +6494,7 @@ ${rule}
6228
6494
  function readCanonPid(taskDir) {
6229
6495
  const file = path19.join(taskDir, PID_FILENAME);
6230
6496
  try {
6231
- const raw = fs19.readFileSync(file, "utf8").trim();
6497
+ const raw = fs20.readFileSync(file, "utf8").trim();
6232
6498
  const pid = Number.parseInt(raw, 10);
6233
6499
  return Number.isInteger(pid) && pid > 0 ? pid : null;
6234
6500
  } catch {
@@ -6237,7 +6503,7 @@ function readCanonPid(taskDir) {
6237
6503
  }
6238
6504
  function removeCanonPid(taskDir) {
6239
6505
  try {
6240
- fs19.unlinkSync(path19.join(taskDir, PID_FILENAME));
6506
+ fs20.unlinkSync(path19.join(taskDir, PID_FILENAME));
6241
6507
  } catch {
6242
6508
  }
6243
6509
  }
@@ -6499,16 +6765,14 @@ function autoCommitCode(taskIds, cwd = REPO_ROOT2) {
6499
6765
  const toStage = handoffFiles.filter((f) => dirtyFiles.has(f));
6500
6766
  const gitIgnoredHandoffFiles = filterGitIgnoredPaths(handoffFiles, cwd);
6501
6767
  const missing = [];
6502
- const settledDeletions = /* @__PURE__ */ new Set();
6503
6768
  const baseRefForLog = getBaseBranch(taskIds);
6504
6769
  for (const f of allHandoffFiles) {
6505
6770
  if (dirtyFiles.has(f)) continue;
6506
6771
  if (gitIgnoredHandoffFiles.has(f)) continue;
6507
- const exists = fs20.existsSync(path20.join(cwd, f));
6772
+ const exists = fs21.existsSync(path20.join(cwd, f));
6508
6773
  if (!exists) {
6509
6774
  const committed = gitSafeAt(cwd, "log", "--format=%H", "--max-count=1", `${baseRefForLog}..HEAD`, "--", f);
6510
6775
  if (committed.ok && committed.stdout.trim()) {
6511
- settledDeletions.add(f);
6512
6776
  continue;
6513
6777
  }
6514
6778
  missing.push(`${f} \u2014 listed in handoff but missing from working tree (and no commit in ${baseRefForLog}..HEAD touches this path)`);
@@ -6544,14 +6808,11 @@ ${stagedBeforeUnexpected.map((f) => ` ${f}`).join("\n")}
6544
6808
  info("Handoff files are already committed or unchanged \u2014 skipping auto-commit.");
6545
6809
  return;
6546
6810
  }
6547
- const stageable = handoffFiles.filter((f) => !settledDeletions.has(f));
6548
- if (stageable.length === 0) {
6549
- verifyHandoffFilesCommitted(taskIds, cwd, handoffFiles);
6550
- info("All handoff files are already settled in history \u2014 skipping auto-commit.");
6551
- return;
6811
+ const stageable = filterStageablePaths(handoffFiles, cwd);
6812
+ if (stageable.length > 0) {
6813
+ const addResult = gitSafeAt(cwd, "add", "-A", "--", ...stageable);
6814
+ if (!addResult.ok) die2(`Failed to stage files: ${addResult.stderr || "unknown error"}`);
6552
6815
  }
6553
- const addResult = gitSafeAt(cwd, "add", "-A", "--", ...stageable);
6554
- if (!addResult.ok) die2(`Failed to stage files: ${addResult.stderr || "unknown error"}`);
6555
6816
  const preCheck = gitSafeAtRaw(cwd, "status", "--porcelain=v1", "-uall");
6556
6817
  const remaining = preCheck.ok ? findUncoveredTrackedChanges(preCheck.stdout, allHandoffFiles) : [];
6557
6818
  const stagedAfter = gitSafeAt(cwd, "diff", "--cached", "--name-only");
@@ -6702,8 +6963,8 @@ function exemptNodeModulesPath(entry, cwd, resolvedWorkspaceDirs) {
6702
6963
  let cwdReal;
6703
6964
  let repoRootReal;
6704
6965
  try {
6705
- cwdReal = fs20.realpathSync(cwd);
6706
- repoRootReal = fs20.realpathSync(REPO_ROOT2);
6966
+ cwdReal = fs21.realpathSync(cwd);
6967
+ repoRootReal = fs21.realpathSync(REPO_ROOT2);
6707
6968
  } catch {
6708
6969
  return null;
6709
6970
  }
@@ -6767,7 +7028,7 @@ function findPullRequestTemplate(repoRoot) {
6767
7028
  path20.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
6768
7029
  ];
6769
7030
  for (const candidate of candidates) {
6770
- if (fs20.existsSync(candidate)) return candidate;
7031
+ if (fs21.existsSync(candidate)) return candidate;
6771
7032
  }
6772
7033
  return null;
6773
7034
  }
@@ -6787,7 +7048,7 @@ function resolveQaPrBody(taskIds, activeCwd) {
6787
7048
  }
6788
7049
  return {
6789
7050
  kind: "fallback",
6790
- reason: fs20.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
7051
+ reason: fs21.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
6791
7052
  };
6792
7053
  }
6793
7054
  function commitQaArtifacts(taskIds, cwd) {
@@ -6832,7 +7093,7 @@ Source or test edits must be committed during the implement phase, not left dirt
6832
7093
  ` + stagedBeforeUnexpected.map((filePath) => ` ${filePath}`).join("\n")
6833
7094
  );
6834
7095
  }
6835
- for (const relPath of stagePaths) {
7096
+ for (const relPath of filterStageablePaths([...stagePaths], cwd)) {
6836
7097
  const exclusions = nodeModulesExclusionArgs(relPath, exemptNodeModulesPaths);
6837
7098
  const addResult = gitSafeAt2(cwd, "add", "-A", "--", relPath, ...exclusions);
6838
7099
  if (!addResult.ok) {
@@ -6915,8 +7176,8 @@ function recordPinnedPRNumber(taskIds, prNum) {
6915
7176
  if (alreadyPinned) return;
6916
7177
  for (const taskId of taskIds) {
6917
7178
  const sidecarPath = sidecarPathFor(taskId);
6918
- fs20.mkdirSync(path20.dirname(sidecarPath), { recursive: true });
6919
- fs20.writeFileSync(sidecarPath, String(prNum), "utf8");
7179
+ fs21.mkdirSync(path20.dirname(sidecarPath), { recursive: true });
7180
+ fs21.writeFileSync(sidecarPath, String(prNum), "utf8");
6920
7181
  }
6921
7182
  }
6922
7183
  function reportOrCreatePR(taskIds, branchName) {
@@ -7037,7 +7298,7 @@ function commitHumanReviewFiles(taskIds, cwd, createPR) {
7037
7298
  );
7038
7299
  }
7039
7300
  const docsRefsScript = path20.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
7040
- if (fs20.existsSync(docsRefsScript)) {
7301
+ if (fs21.existsSync(docsRefsScript)) {
7041
7302
  const docsRefsResult = spawnSync6("node", [docsRefsScript], {
7042
7303
  cwd,
7043
7304
  stdio: ["ignore", "pipe", "pipe"],
@@ -7067,27 +7328,12 @@ This failure cannot be bypassed with --force.`
7067
7328
  );
7068
7329
  } else if (baseDriftResult.drift.length > 0) {
7069
7330
  if (!cliArgs.force) {
7070
- die2(
7071
- `--pr aborted: base-drift detected. Files in the tree diff between origin/${baseBranch}
7072
- and HEAD that are not in the spec's Affected Files (and not task-dir/telemetry):
7073
- ${baseDriftResult.drift.map((filePath) => ` ${filePath}`).join("\n")}
7074
- The allowlist is: tasks/<id>/**, PIPELINE_TELEMETRY_FILES, files listed in
7075
- your spec's '### Affected Files' table (directory-form entries like 'dist/' match
7076
- subpaths), and PIPELINE_MANAGED_DOCS (auto-allowlisted once qa.status = done).
7077
- If this is a legitimate task change, add the path to spec.md '### Affected Files'
7078
- and rerun. For a rename, list BOTH the old and new paths. If the drift is
7079
- unexpected (likely cross-pipeline contamination from a sibling worktree's
7080
- managed-doc sync, OR a third-party commit landed on origin/${baseBranch} while
7081
- this pipeline was running), recover with one of:
7082
- - rebase onto current origin/${baseBranch} to absorb the base advance:
7083
- git fetch origin ${baseBranch} && git rebase origin/${baseBranch}
7084
- - reset a specific file to base's content if a stray task-branch commit
7085
- introduced it:
7086
- git checkout origin/${baseBranch} -- <path> && git commit -m 'revert drift on <path>'
7087
- - revert the offending task-branch commit entirely:
7088
- git revert <sha>
7089
- Bypass with --force if you've verified the drift is intentional.`
7331
+ const taskChangedFiles = new Set(getAffectedFiles(`origin/${baseBranch}`, cwd));
7332
+ const classification = classifyBaseDriftFilesFromData(
7333
+ baseDriftResult.drift,
7334
+ taskChangedFiles
7090
7335
  );
7336
+ die2(buildBaseDriftAbortMessage(baseBranch, classification));
7091
7337
  }
7092
7338
  warn2(
7093
7339
  `--force override: base-drift detected; proceeding at user request. Drifted files:
@@ -7191,7 +7437,7 @@ If this is a source or test file, it should have been committed during the imple
7191
7437
  Unstage them or list them in the task artifacts before rerunning.`
7192
7438
  );
7193
7439
  }
7194
- for (const relPath of stagePaths) {
7440
+ for (const relPath of filterStageablePaths([...stagePaths], cwd)) {
7195
7441
  const exclusions = nodeModulesExclusionArgs(relPath, exemptNodeModulesPaths);
7196
7442
  const addResult = gitSafeAt2(cwd, "add", "-A", "--", relPath, ...exclusions);
7197
7443
  if (!addResult.ok) {
@@ -7399,7 +7645,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
7399
7645
  const sidecarPath = sidecarPathFor(taskId, taskDir);
7400
7646
  let raw;
7401
7647
  try {
7402
- raw = fs20.readFileSync(sidecarPath, "utf8").trim();
7648
+ raw = fs21.readFileSync(sidecarPath, "utf8").trim();
7403
7649
  } catch {
7404
7650
  return null;
7405
7651
  }
@@ -7546,7 +7792,7 @@ function mergeOpenPRsAndPull(taskIds, baseBranch, branchByTaskId) {
7546
7792
  }
7547
7793
  function runPostMergeHook() {
7548
7794
  const hookPath = path20.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
7549
- if (!fs20.existsSync(hookPath)) return;
7795
+ if (!fs21.existsSync(hookPath)) return;
7550
7796
  info2("Running .canon/hooks/post-merge.sh...");
7551
7797
  const result = runCommand2("bash", [hookPath]);
7552
7798
  if (!result.ok) {
@@ -7574,8 +7820,8 @@ function rewriteArchivedTaskRefs(taskIds) {
7574
7820
  path20.join(REPO_ROOT2, "docs", "task-quality-log.md")
7575
7821
  ];
7576
7822
  for (const filePath of targets) {
7577
- if (!fs20.existsSync(filePath)) continue;
7578
- let content = fs20.readFileSync(filePath, "utf8");
7823
+ if (!fs21.existsSync(filePath)) continue;
7824
+ let content = fs21.readFileSync(filePath, "utf8");
7579
7825
  let changed = false;
7580
7826
  for (const taskId of taskIds) {
7581
7827
  const stale = `tasks/${taskId}/`;
@@ -7586,7 +7832,7 @@ function rewriteArchivedTaskRefs(taskIds) {
7586
7832
  }
7587
7833
  }
7588
7834
  if (changed) {
7589
- fs20.writeFileSync(filePath, content, "utf8");
7835
+ fs21.writeFileSync(filePath, content, "utf8");
7590
7836
  info2(`Updated stale task refs in ${path20.relative(REPO_ROOT2, filePath)}.`);
7591
7837
  }
7592
7838
  }
@@ -7620,7 +7866,7 @@ function classifyAndPreserveSharedDocDirt() {
7620
7866
  if (porcelainCode !== " M") {
7621
7867
  return { relPath, docClass, porcelainCode, headContent: null, workingContent: null };
7622
7868
  }
7623
- const workingContent = fs20.readFileSync(path20.join(REPO_ROOT2, relPath), "utf8");
7869
+ const workingContent = fs21.readFileSync(path20.join(REPO_ROOT2, relPath), "utf8");
7624
7870
  const headResult = gitSafeAtRaw(REPO_ROOT2, "show", `HEAD:${relPath}`);
7625
7871
  return {
7626
7872
  relPath,
@@ -7637,11 +7883,11 @@ function classifyAndPreserveSharedDocDirt() {
7637
7883
  }
7638
7884
  const preserve = verdict.preserve;
7639
7885
  if (preserve.length === 0) return [];
7640
- const backupDir = fs20.mkdtempSync(path20.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
7886
+ const backupDir = fs21.mkdtempSync(path20.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
7641
7887
  const preserved = [];
7642
7888
  for (const { relPath, suffix } of preserve) {
7643
7889
  const backupPath = path20.join(backupDir, relPath.replace(/[\\/]/g, "__"));
7644
- fs20.writeFileSync(backupPath, suffix, "utf8");
7890
+ fs21.writeFileSync(backupPath, suffix, "utf8");
7645
7891
  info2(`Preserving uncommitted ${relPath} dirt during --ship; backup: ${backupPath}`);
7646
7892
  const checkoutResult = gitSafe("checkout", "HEAD", "--", relPath);
7647
7893
  if (!checkoutResult.ok) {
@@ -7669,7 +7915,7 @@ function shipTasks(taskIds) {
7669
7915
  path20.join(taskDirForRepoRoot2(taskId), "status.json")
7670
7916
  ];
7671
7917
  for (const candidate of candidates) {
7672
- if (fs20.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
7918
+ if (fs21.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
7673
7919
  }
7674
7920
  const snapshot = taskStatuses.get(taskId);
7675
7921
  if (snapshot) return snapshot;
@@ -7810,7 +8056,7 @@ Recovery:
7810
8056
  );
7811
8057
  }
7812
8058
  const archiveDir = path20.join(TASKS_DIR2, "_archive");
7813
- if (!fs20.existsSync(archiveDir)) fs20.mkdirSync(archiveDir, { recursive: true });
8059
+ if (!fs21.existsSync(archiveDir)) fs21.mkdirSync(archiveDir, { recursive: true });
7814
8060
  const localBranchesToDelete = [];
7815
8061
  for (const taskId of taskIds) {
7816
8062
  const { worktree: hasWorktree } = taskSnapshot(taskId);
@@ -7822,7 +8068,7 @@ Recovery:
7822
8068
  writeStatusToFile(path20.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
7823
8069
  const src = taskDirForRepoRoot2(taskId);
7824
8070
  const dest = path20.join(archiveDir, taskId);
7825
- fs20.renameSync(src, dest);
8071
+ fs21.renameSync(src, dest);
7826
8072
  info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
7827
8073
  const branchName = taskSnapshot(taskId).branch;
7828
8074
  if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
@@ -7838,8 +8084,8 @@ Recovery:
7838
8084
  ]);
7839
8085
  stageArchiveChanges(stagedPaths);
7840
8086
  for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
7841
- fs20.appendFileSync(path20.join(REPO_ROOT2, relPath), suffix, "utf8");
7842
- fs20.rmSync(backupPath, { force: true });
8087
+ fs21.appendFileSync(path20.join(REPO_ROOT2, relPath), suffix, "utf8");
8088
+ fs21.rmSync(backupPath, { force: true });
7843
8089
  info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
7844
8090
  }
7845
8091
  const archiveCommit = commitArchiveChanges(taskIds, baseBranch);
@@ -8090,7 +8336,7 @@ async function runPhase(phase, state) {
8090
8336
  if (phase === "qa") {
8091
8337
  const activeCwd = getActiveCwd(taskIds);
8092
8338
  const qaTemplatePath = state.isBundle ? null : findPullRequestTemplate(activeCwd) ?? findPullRequestTemplate(REPO_ROOT2);
8093
- const resolvedPrTemplate = qaTemplatePath ? fs20.readFileSync(qaTemplatePath, "utf8") : null;
8339
+ const resolvedPrTemplate = qaTemplatePath ? fs21.readFileSync(qaTemplatePath, "utf8") : null;
8094
8340
  return runQaPhase(state, cliArgs.interactive, resolvedPrTemplate);
8095
8341
  }
8096
8342
  if (phase === "human_review") {
@@ -8173,7 +8419,7 @@ var extractCheckedVerdict2 = extractCheckedVerdict;
8173
8419
  function readArtifact(taskId, name) {
8174
8420
  const p = path20.join(taskDirFor2(taskId), name);
8175
8421
  try {
8176
- return fs20.readFileSync(p, "utf8");
8422
+ return fs21.readFileSync(p, "utf8");
8177
8423
  } catch {
8178
8424
  return null;
8179
8425
  }
@@ -8197,7 +8443,7 @@ function checkImplementEvidence(taskId) {
8197
8443
  const sForEvidence = readStatus(taskId);
8198
8444
  if (sForEvidence.worktree === true) {
8199
8445
  const wt = worktreePath(taskId);
8200
- if (fs20.existsSync(wt)) checkRoots.push(wt);
8446
+ if (fs21.existsSync(wt)) checkRoots.push(wt);
8201
8447
  }
8202
8448
  const ignoreCwd = checkRoots[checkRoots.length - 1];
8203
8449
  const gitIgnored = filterGitIgnoredPaths(files, ignoreCwd);
@@ -8209,12 +8455,13 @@ function checkImplementEvidence(taskId) {
8209
8455
  };
8210
8456
  }
8211
8457
  const existingFiles = verifiableFiles.filter(
8212
- (f) => checkRoots.some((root) => fs20.existsSync(path20.join(root, f)))
8458
+ (f) => checkRoots.some((root) => fs21.existsSync(path20.join(root, f)))
8213
8459
  );
8214
8460
  if (existingFiles.length === 0) {
8215
8461
  const evidenceCwd = checkRoots[checkRoots.length - 1];
8216
- const deletedInWorkingTree = new Set(
8217
- gitSafeAt(evidenceCwd, "ls-files", "--deleted").stdout.split("\n").map((l) => l.trim()).filter(Boolean)
8462
+ const deletedDiff = gitSafeAt(evidenceCwd, "diff", "HEAD", "--name-only", "--no-renames", "--diff-filter=D");
8463
+ const deletedAgainstHead = new Set(
8464
+ (deletedDiff.ok ? deletedDiff.stdout : "").split("\n").map((l) => l.trim()).filter(Boolean)
8218
8465
  );
8219
8466
  const baseBranch = sForEvidence.base_branch || getDefaultBaseBranch();
8220
8467
  const committedDiff = gitSafeAt(
@@ -8227,7 +8474,7 @@ function checkImplementEvidence(taskId) {
8227
8474
  committedDiff.stdout.split("\n").filter((l) => l.startsWith("D")).map((l) => l.split(" ")[1]?.trim()).filter((p) => Boolean(p))
8228
8475
  );
8229
8476
  const deletedFiles = verifiableFiles.filter(
8230
- (f) => deletedInWorkingTree.has(f) || deletedInCommits.has(f)
8477
+ (f) => deletedAgainstHead.has(f) || deletedInCommits.has(f)
8231
8478
  );
8232
8479
  if (deletedFiles.length === 0) {
8233
8480
  return { advanced: false, note: `handoff.md lists ${files.length} file(s) but none exist on disk or are git-tracked deletions` };
@@ -8430,6 +8677,35 @@ async function checkAndRoute(phase, taskIds) {
8430
8677
  }
8431
8678
  }
8432
8679
  statuses = taskIds.map(readStatus);
8680
+ let specGapScopeFiles = [];
8681
+ if (phase === "code_review" && statuses.every((status) => status.full_send === true)) {
8682
+ const cwd = getActiveCwd(taskIds);
8683
+ const baseBranch = getBaseBranch(taskIds);
8684
+ const changedFiles = getAffectedFiles(baseBranch, cwd);
8685
+ const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs: true });
8686
+ const verdicts = statuses.map((status) => getVerdict(status, "code_review"));
8687
+ if (verdicts.includes("spec_gap")) {
8688
+ specGapScopeFiles = verifyBaseDriftFromData(
8689
+ changedFiles,
8690
+ allowlist.paths,
8691
+ taskIds,
8692
+ allowlist.prefixes
8693
+ );
8694
+ }
8695
+ const unjudged = findUnjudgedFullSendFilesFromData(
8696
+ changedFiles,
8697
+ allowlist,
8698
+ taskIds,
8699
+ verdicts
8700
+ );
8701
+ if (unjudged.length > 0) {
8702
+ const reason = fullSendScopeBlockReason(taskIds, unjudged, verdicts.filter(Boolean));
8703
+ warn2(reason);
8704
+ const maxIter = statuses.reduce((max, status) => Math.max(max, getIterations(status)), 0);
8705
+ autoBlockPhase(taskIds, "code_review", maxIter, reason);
8706
+ process.exit(2);
8707
+ }
8708
+ }
8433
8709
  if (lastCodexExitStatus !== 0) {
8434
8710
  warn2(`Phase '${phase}' completed despite Codex exit status ${lastCodexExitStatus} (likely MCP warnings). Continuing.`);
8435
8711
  lastCodexExitStatus = 0;
@@ -8505,7 +8781,8 @@ async function checkAndRoute(phase, taskIds) {
8505
8781
  const specGapIds = taskIds.filter((_, index) => getVerdict(statuses[index], "code_review") === "spec_gap");
8506
8782
  if (specGapIds.length > 0) {
8507
8783
  const maxIter = statuses.reduce((max, s) => Math.max(max, getIterations(s)), 0);
8508
- const reason = `Code review surfaced a spec_gap verdict for task(s): ${specGapIds.join(", ")}. The implementation cannot resolve this \u2014 the root cause is in the spec. Recovery options (both operate on the full blocked bundle [${taskIds.join(" ")}]):
8784
+ const scopeNote = specGapScopeFiles.length > 0 ? ` Full-send files remain outside Affected Files: ${specGapScopeFiles.join(", ")}. A BLESS accepts the verdict but does not amend the spec, so --pr will still reject these files: before blessing, add them to the spec's Affected Files (as an ## Amendment) or remove them from the branch.` : "";
8785
+ const reason = `Code review surfaced a spec_gap verdict for task(s): ${specGapIds.join(", ")}. The implementation cannot resolve this \u2014 the root cause is in the spec. ` + scopeNote + `Recovery options (both operate on the full blocked bundle [${taskIds.join(" ")}]):
8509
8786
  FIX: amend spec.md with ## Amendment, then: canon run ${taskIds.join(" ")} --reroute
8510
8787
  BLESS: canon task accept ${taskIds.join(" ")} code_review --reason "<why>"`;
8511
8788
  console.log("");
@@ -8515,6 +8792,12 @@ async function checkAndRoute(phase, taskIds) {
8515
8792
  console.log(" The code review found a problem in the spec, not a fixable");
8516
8793
  console.log(" implementation bug. Review the findings:");
8517
8794
  for (const id of specGapIds) console.log(` tasks/${id}/review.md`);
8795
+ if (specGapScopeFiles.length > 0) {
8796
+ console.log(" Full-send files still outside Affected Files:");
8797
+ for (const file of specGapScopeFiles) console.log(` ${file}`);
8798
+ console.log(" BLESS does not amend the spec, so --pr will still reject these files.");
8799
+ console.log(" Before blessing, add them to Affected Files (## Amendment) or remove them.");
8800
+ }
8518
8801
  console.log("");
8519
8802
  console.log(" Two recovery options:");
8520
8803
  console.log("");
@@ -8565,8 +8848,8 @@ function checkDeps(taskIds, skipAgentDeps = false) {
8565
8848
  for (const taskId of taskIds) {
8566
8849
  validateTaskId(taskId);
8567
8850
  const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", taskId, "status.json");
8568
- const statusFile = cliArgs.ship && fs20.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
8569
- if (!fs20.existsSync(statusFile)) {
8851
+ const statusFile = cliArgs.ship && fs21.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
8852
+ if (!fs21.existsSync(statusFile)) {
8570
8853
  die(`No status.json at tasks/${taskId}/status.json \u2014 run canon task new ${taskId} first`);
8571
8854
  }
8572
8855
  }
@@ -8608,7 +8891,7 @@ async function main() {
8608
8891
  let heartbeatStarted = false;
8609
8892
  const earlyHeartbeatResolver = (id) => {
8610
8893
  const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", id, "status.json");
8611
- return path20.dirname(cliArgs.ship && fs20.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
8894
+ return path20.dirname(cliArgs.ship && fs21.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
8612
8895
  };
8613
8896
  if (!cliArgs.ship && !cliArgs.dryRun) {
8614
8897
  guardConcurrentRun(cliArgs.taskIds, earlyHeartbeatResolver);