canon-ai 3.3.1 → 3.5.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,45 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.5.0] — 2026-09-29
8
+
9
+ ### Added
10
+
11
+ - **A reviewer that can't reach a verdict now stops the run for a human.** If a code-review lens returns nothing usable twice, the foreman sets `code_review` to `blocked` instead of being pushed to invent a verdict; recover with `canon task reset-code-review` for each bundle member, then `canon run`.
12
+ - **Plans include a short feasibility check.** Pipeline, reroute, and `/canon-spec` XS plans now confirm the code they rely on exists, trace the real call path and callers, and name the input that makes each new test fail without the fix.
13
+ - **`CLAUDE_MODEL_LIGHT` and `CLAUDE_MODEL_STRONG` override each tier's model.** Existing per-phase pins and `CLAUDE_MODEL` still work and take precedence in that order.
14
+
15
+ ### Changed
16
+
17
+ - **Claude defaults are re-baselined for the 5.5 generation.** Spec, plan, and code review run Sonnet `medium` for XS/S, Opus `medium` for M/L, and Opus `high` for XL/delicate; QA runs Sonnet `medium` at every size, and nothing runs at `xhigh`. A model pin now replaces only the model, so a Sonnet pin on M/L review or plan drops from `high` to `medium` effort.
18
+ - **Unattended Claude phases get an explicit unattended-session contract.** Like Codex's headless preamble, it says no one will answer questions, finishing means the artifact is written and the phase command has run, and the phase must not start other phases or reviewers.
19
+ - **The code-review foreman accepts clean lens results and bounds delegation.** A lens's empty result is now valid rather than "a bug in the lens," and the foreman spawns only its two lenses plus one re-spawn.
20
+ - **`/canon-spec-review` no longer requires a task ID.** With no argument it reviews the task under discussion, or asks which one when that's ambiguous.
21
+
22
+ ## [3.4.0] — 2026-09-27
23
+
24
+ ### Added
25
+
26
+ - **`code_review` catches files outside the spec's Affected Files at pre-flight instead of at `--pr`.** Previously, a helper, fixture, or rebuilt file the spec didn't list could pass `code_review`, only for `--pr`'s base-drift check to reject it. That forced a spec edit and a second `--pr`. The same scope check now runs just before `code_review`. A normal run halts before any reviewer starts, names the file, and offers two fixes: add it to Affected Files and re-run, or reroute to remove it. In a full-send run, the code-review foreman judges each out-of-scope file instead. It classes each one as an appropriate scope expansion, a spec miss, or something that shouldn't have changed, and records the outcome in `review.md`. For the first two, it also adds a `## Amendment (full-send scope)` section to the spec. That heading does not satisfy the human amendment gate on `--reroute`. `--pr`'s base-drift message now says whether the base branch advanced or the file is out of scope. It no longer labels both cases as scope violations.
27
+ - **The anchored code-review lens checks changed code against `docs/patterns.md`.** When the project keeps that file, the lens reads it first, using its Trigger Table if it has one. It reports each violation of a written project rule as a finding that names the rule, not as a nit. The check runs every round and covers only code the diff changes.
28
+
29
+ ### Changed
30
+
31
+ - **`code_review` re-rounds review only the fix, not the whole diff, when that's safe.** A full review still runs in these cases:
32
+ - Round 1.
33
+ - Every round of an XL or delicate task.
34
+ - Any round where the previous round's record is missing or unusable.
35
+ - The fix touches files outside the previous round's change set.
36
+ - The fix exceeds a size threshold.
37
+
38
+ Otherwise, cold-Codex and the cold-Claude lens review only the changes since the commit the previous round reviewed. The anchored lens keeps the full diff, spec, and handoff. No lens is skipped. `review.md` states each round's scope, base, and reason. Each cold-Codex round now writes its own `tasks/<id>/review-cold-codex-run-N.md` instead of overwriting the previous one. The cold-Claude lens also gains a standing sibling-site sweep: when a diff adds or changes a guard or invariant, it looks for other sites that need the same treatment.
39
+ - **The handoff template's guidance on directory paths matches `docs-refs-check`.** Backticking an existing directory is fine. A directory the task deleted should be described in prose or written as a markdown link.
40
+
41
+ ### Fixed
42
+
43
+ - **The post-implement auto-commit no longer fails when the spec's Affected Files lists a deleted or renamed file.** A path that git had already staged as deleted or renamed away made `git add` fail with "pathspec did not match". All three commit sites now drop paths that are neither tracked nor on disk before staging.
44
+ - **`docs-refs-check` accepts backticked paths to existing directories.** It had reported them as missing files.
45
+
7
46
  ## [3.3.1] — 2026-09-24
8
47
 
9
48
  ### Fixed
package/README.md CHANGED
@@ -86,7 +86,7 @@ The pipeline supports two tiers:
86
86
  - **Fast tier** (XS tasks): spec + plan in one Claude session, skip Codex spec review, human gate replaces it.
87
87
  - **Full tier** (S / M / L / XL / delicate tasks): every phase runs separately, Codex reviews specs before they reach the human.
88
88
 
89
- `delicate: true` (set in `status.json` at task creation) promotes any task to full tier and upgrades the orchestrator's model and effort across every phase. Use it for sensitive surfaces — auth, payments, persistent storage, anything where a regression has unbounded blast radius.
89
+ `delicate: true` (set in `status.json` at task creation) promotes any task to full tier and upgrades the model and effort for spec, plan, and code review, plus Codex spec review and implementation; QA and the cold-Codex review lens stay on their regular cells. Use it for sensitive surfaces — auth, payments, persistent storage, anything where a regression has unbounded blast radius.
90
90
 
91
91
  A single command runs a task end-to-end:
92
92
 
package/dist/cli/index.js CHANGED
@@ -555,20 +555,6 @@ import path4 from "path";
555
555
  import { fileURLToPath } from "url";
556
556
  var __filename = fileURLToPath(import.meta.url);
557
557
  var __dirname = path4.dirname(__filename);
558
- function parseMaxReviewLoops(raw) {
559
- if (!raw) return null;
560
- const trimmed = raw.trim();
561
- if (!/^-?\d+$/.test(trimmed)) {
562
- warn(`Invalid MAX_REVIEW_LOOPS value "${raw}"; using the size-aware default.`);
563
- return null;
564
- }
565
- const parsed = Number(trimmed);
566
- if (!Number.isInteger(parsed) || parsed < 0) {
567
- warn(`Invalid MAX_REVIEW_LOOPS value "${raw}"; using the size-aware default.`);
568
- return null;
569
- }
570
- return parsed;
571
- }
572
558
  function resolveRepoRoot() {
573
559
  try {
574
560
  const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
@@ -601,15 +587,6 @@ function resolveProjectName() {
601
587
  }
602
588
  var config = {
603
589
  projectName: resolveProjectName(),
604
- claudeBudget: process.env.CLAUDE_BUDGET ?? null,
605
- claudeModelSpec: process.env.CLAUDE_MODEL_SPEC ?? process.env.CLAUDE_MODEL ?? "opus",
606
- claudeModelPlan: process.env.CLAUDE_MODEL_PLAN ?? process.env.CLAUDE_MODEL ?? "sonnet",
607
- claudeModelReview: process.env.CLAUDE_MODEL_REVIEW ?? process.env.CLAUDE_MODEL ?? "sonnet",
608
- claudeModelReviewLarge: process.env.CLAUDE_MODEL_REVIEW_LARGE ?? process.env.CLAUDE_MODEL ?? "opus",
609
- claudeModelQa: process.env.CLAUDE_MODEL_QA ?? process.env.CLAUDE_MODEL ?? "sonnet",
610
- codexModelMini: process.env.CODEX_MODEL_MINI ?? process.env.CODEX_MODEL_DEFAULT ?? "gpt-6-luna",
611
- codexModelFull: process.env.CODEX_MODEL_FULL ?? process.env.CODEX_MODEL_DELICATE ?? "gpt-6-sol",
612
- maxReviewLoops: parseMaxReviewLoops(process.env.MAX_REVIEW_LOOPS),
613
590
  maxContextBytes: Number.parseInt(process.env.MAX_CONTEXT_BYTES ?? String(64 * 1024), 10)
614
591
  };
615
592
 
@@ -1247,7 +1224,7 @@ function checkTemplates(cwd) {
1247
1224
  }
1248
1225
  function checkCanonVersion(cwd) {
1249
1226
  const versionPath = join(cwd, ".canon", "version");
1250
- const installedVersion = "3.3.1";
1227
+ const installedVersion = "3.5.0";
1251
1228
  if (!existsSync(versionPath)) {
1252
1229
  return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
1253
1230
  }
@@ -1744,7 +1721,7 @@ function initCmd(_args) {
1744
1721
  }
1745
1722
  function writeCanonVersion(cwd) {
1746
1723
  const versionPath = join2(cwd, ".canon", "version");
1747
- const version = "3.3.1";
1724
+ const version = "3.5.0";
1748
1725
  mkdirSync(dirname2(versionPath), { recursive: true });
1749
1726
  writeFileSync(versionPath, version + "\n");
1750
1727
  }
@@ -2696,16 +2673,17 @@ function watchCmd(args2, deps = {}) {
2696
2673
 
2697
2674
  // src/task/index.ts
2698
2675
  import { spawnSync as spawnSync7 } from "child_process";
2699
- import fs13 from "fs";
2676
+ import fs14 from "fs";
2700
2677
  import path14 from "path";
2701
2678
 
2702
2679
  // src/orchestrator/canon-snapshot.ts
2703
2680
  import { spawnSync as spawnSync6 } from "child_process";
2704
- import fs9 from "fs";
2681
+ import fs10 from "fs";
2705
2682
  import path10 from "path";
2706
2683
 
2707
2684
  // src/orchestrator/git.ts
2708
2685
  import { spawnSync as spawnSync5 } from "child_process";
2686
+ import fs9 from "fs";
2709
2687
  import path9 from "path";
2710
2688
 
2711
2689
  // src/orchestrator/worktree.ts
@@ -2753,7 +2731,7 @@ function isInstalledSourcePath(sourcePath) {
2753
2731
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
2754
2732
  }
2755
2733
  function resolveCanonVersion(explicit) {
2756
- return explicit ?? "3.3.1" ?? "dev";
2734
+ return explicit ?? "3.5.0" ?? "dev";
2757
2735
  }
2758
2736
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
2759
2737
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
@@ -2832,24 +2810,24 @@ function applyCanonSnapshot(status, canon) {
2832
2810
  return next;
2833
2811
  }
2834
2812
  function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
2835
- const status = JSON.parse(fs9.readFileSync(statusFilePath, "utf8"));
2813
+ const status = JSON.parse(fs10.readFileSync(statusFilePath, "utf8"));
2836
2814
  const canon = captureCanonSnapshot(REPO_ROOT, options);
2837
2815
  const next = applyCanonSnapshot(status, canon);
2838
2816
  const serialized = `${JSON.stringify(next, null, 2)}
2839
2817
  `;
2840
- const current = fs9.readFileSync(statusFilePath, "utf8");
2818
+ const current = fs10.readFileSync(statusFilePath, "utf8");
2841
2819
  if (current !== serialized) {
2842
- fs9.writeFileSync(statusFilePath, serialized, "utf8");
2820
+ fs10.writeFileSync(statusFilePath, serialized, "utf8");
2843
2821
  }
2844
2822
  return canon;
2845
2823
  }
2846
2824
 
2847
2825
  // src/orchestrator/review-archive.ts
2848
- import fs12 from "fs";
2826
+ import fs13 from "fs";
2849
2827
  import path13 from "path";
2850
2828
 
2851
2829
  // src/task/templates.ts
2852
- import fs10 from "fs";
2830
+ import fs11 from "fs";
2853
2831
  import path11 from "path";
2854
2832
  function tasksRoot() {
2855
2833
  return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
@@ -2862,17 +2840,17 @@ function taskTemplateOverrideRoot() {
2862
2840
  }
2863
2841
  function resolveTaskTemplateSource(basename2) {
2864
2842
  const override = path11.join(taskTemplateOverrideRoot(), basename2);
2865
- if (fs10.existsSync(override)) return override;
2843
+ if (fs11.existsSync(override)) return override;
2866
2844
  const managed = path11.join(templatesRoot(), basename2);
2867
- return fs10.existsSync(managed) ? managed : null;
2845
+ return fs11.existsSync(managed) ? managed : null;
2868
2846
  }
2869
2847
  function renderTaskTemplate(source, taskId, title) {
2870
- return fs10.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
2848
+ return fs11.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
2871
2849
  }
2872
2850
  function scaffoldTaskArtifact(taskDir, basename2, taskId, title) {
2873
2851
  const source = resolveTaskTemplateSource(basename2);
2874
2852
  if (!source) return null;
2875
- fs10.writeFileSync(path11.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
2853
+ fs11.writeFileSync(path11.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
2876
2854
  return source;
2877
2855
  }
2878
2856
  function isPristineTaskArtifact(content, basename2, taskId, title) {
@@ -2887,7 +2865,7 @@ function isPristineTaskArtifact(content, basename2, taskId, title) {
2887
2865
  }
2888
2866
 
2889
2867
  // src/orchestrator/validation.ts
2890
- import fs11 from "fs";
2868
+ import fs12 from "fs";
2891
2869
  import path12 from "path";
2892
2870
  function computeLatestValidationResults(handoffContent) {
2893
2871
  const latest = /* @__PURE__ */ new Map();
@@ -3019,7 +2997,7 @@ function isTemplateUnfilled(content) {
3019
2997
  function isDoneMdTemplate(donePath) {
3020
2998
  let content;
3021
2999
  try {
3022
- content = fs11.readFileSync(donePath, "utf8");
3000
+ content = fs12.readFileSync(donePath, "utf8");
3023
3001
  } catch {
3024
3002
  return true;
3025
3003
  }
@@ -3058,7 +3036,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3058
3036
  const artifactPath = path12.join(taskDir, config2.artifactName);
3059
3037
  let content;
3060
3038
  try {
3061
- content = fs11.readFileSync(artifactPath, "utf8");
3039
+ content = fs12.readFileSync(artifactPath, "utf8");
3062
3040
  } catch {
3063
3041
  return { ok: false, reason: `${config2.artifactName} is missing for phase '${phase}'` };
3064
3042
  }
@@ -3070,7 +3048,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3070
3048
  if (phase === "spec_review" || phase === "plan") {
3071
3049
  let statusRaw;
3072
3050
  try {
3073
- statusRaw = fs11.readFileSync(path12.join(taskDir, "status.json"), "utf8");
3051
+ statusRaw = fs12.readFileSync(path12.join(taskDir, "status.json"), "utf8");
3074
3052
  } catch {
3075
3053
  return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
3076
3054
  }
@@ -3108,7 +3086,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3108
3086
  const handoffPath = path12.join(taskDir, "handoff.md");
3109
3087
  let handoffContent;
3110
3088
  try {
3111
- handoffContent = fs11.readFileSync(handoffPath, "utf8");
3089
+ handoffContent = fs12.readFileSync(handoffPath, "utf8");
3112
3090
  } catch {
3113
3091
  return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
3114
3092
  }
@@ -3117,7 +3095,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3117
3095
  const donePath = path12.join(taskDir, "done.md");
3118
3096
  let doneContent = "";
3119
3097
  try {
3120
- doneContent = fs11.readFileSync(donePath, "utf8");
3098
+ doneContent = fs12.readFileSync(donePath, "utf8");
3121
3099
  } catch {
3122
3100
  }
3123
3101
  if (hasHumanPendingWaiver(doneContent)) return { ok: true };
@@ -3135,7 +3113,7 @@ function parseHandoffChangesRows(taskId) {
3135
3113
  const handoffPath = path12.join(taskDirFor(taskId), "handoff.md");
3136
3114
  let content;
3137
3115
  try {
3138
- content = fs11.readFileSync(handoffPath, "utf8");
3116
+ content = fs12.readFileSync(handoffPath, "utf8");
3139
3117
  } catch {
3140
3118
  return { files: [], malformed: [] };
3141
3119
  }
@@ -3439,7 +3417,7 @@ var REVIEW_ARCHIVE_PREFIX = "review-prior-";
3439
3417
  var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
3440
3418
  function newestReviewArchiveNumber(taskDir) {
3441
3419
  let newest = 0;
3442
- for (const name of fs12.readdirSync(taskDir)) {
3420
+ for (const name of fs13.readdirSync(taskDir)) {
3443
3421
  const match = REVIEW_ARCHIVE_RE.exec(name);
3444
3422
  if (match) newest = Math.max(newest, Number(match[1]));
3445
3423
  }
@@ -3447,21 +3425,21 @@ function newestReviewArchiveNumber(taskDir) {
3447
3425
  }
3448
3426
  function archivePriorReview(taskDir, options = {}) {
3449
3427
  const reviewPath = path13.join(taskDir, "review.md");
3450
- if (!fs12.existsSync(reviewPath)) return null;
3428
+ if (!fs13.existsSync(reviewPath)) return null;
3451
3429
  if (options.skipUnfilledTemplate) {
3452
- const content = fs12.readFileSync(reviewPath, "utf8");
3430
+ const content = fs13.readFileSync(reviewPath, "utf8");
3453
3431
  if (isTemplateUnfilled(content)) return null;
3454
3432
  if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
3455
3433
  return null;
3456
3434
  }
3457
3435
  }
3458
3436
  const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
3459
- fs12.renameSync(reviewPath, path13.join(taskDir, archiveName));
3437
+ fs13.renameSync(reviewPath, path13.join(taskDir, archiveName));
3460
3438
  return archiveName;
3461
3439
  }
3462
3440
  function rescaffoldReview(taskDir, identity) {
3463
3441
  try {
3464
- if (fs12.existsSync(path13.join(taskDir, "review.md"))) return { outcome: "exists" };
3442
+ if (fs13.existsSync(path13.join(taskDir, "review.md"))) return { outcome: "exists" };
3465
3443
  const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
3466
3444
  return source ? { outcome: "written", source } : { outcome: "no-template" };
3467
3445
  } catch (error) {
@@ -3534,7 +3512,7 @@ function taskRootForGate(cwd) {
3534
3512
  }
3535
3513
  function readJsonFile(filePath) {
3536
3514
  try {
3537
- return JSON.parse(fs13.readFileSync(filePath, "utf8"));
3515
+ return JSON.parse(fs14.readFileSync(filePath, "utf8"));
3538
3516
  } catch (error) {
3539
3517
  const message = error instanceof Error ? error.message : String(error);
3540
3518
  throw new Error(`Error: failed to read ${filePath}: ${message}`);
@@ -3542,9 +3520,9 @@ function readJsonFile(filePath) {
3542
3520
  }
3543
3521
  function writeJsonAtomic(filePath, data) {
3544
3522
  const tmpFile = `${filePath}.tmp`;
3545
- fs13.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3523
+ fs14.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3546
3524
  `, "utf8");
3547
- fs13.renameSync(tmpFile, filePath);
3525
+ fs14.renameSync(tmpFile, filePath);
3548
3526
  }
3549
3527
  function writeStatusAtomic(filePath, status) {
3550
3528
  status.status = deriveTopLevelStatus(status);
@@ -3585,15 +3563,15 @@ function currentBranchOrEmpty() {
3585
3563
  }
3586
3564
  function listTemplateFiles() {
3587
3565
  const root = templatesRoot();
3588
- if (!fs13.existsSync(root)) {
3566
+ if (!fs14.existsSync(root)) {
3589
3567
  throw new Error(`Error: templates directory not found at ${root}`);
3590
3568
  }
3591
- return fs13.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3569
+ return fs14.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3592
3570
  }
3593
3571
  function printCreatedTask(taskDir, baseBranch) {
3594
3572
  console.log(`Created task: ${taskDir}`);
3595
3573
  console.log("Files:");
3596
- for (const file of fs13.readdirSync(taskDir).sort()) {
3574
+ for (const file of fs14.readdirSync(taskDir).sort()) {
3597
3575
  console.log(file);
3598
3576
  }
3599
3577
  console.log("");
@@ -3631,16 +3609,16 @@ function taskNew(args2) {
3631
3609
  throw new Error("Error: title must be single-line (no embedded newlines).");
3632
3610
  }
3633
3611
  const taskDir = taskDirFromRoot(id);
3634
- if (fs13.existsSync(taskDir)) {
3612
+ if (fs14.existsSync(taskDir)) {
3635
3613
  throw new Error(`Error: Task directory ${taskDir} already exists.`);
3636
3614
  }
3637
3615
  if (!baseBranch) {
3638
3616
  baseBranch = currentBranchOrEmpty() || "main";
3639
3617
  }
3640
- fs13.mkdirSync(taskDir, { recursive: true });
3618
+ fs14.mkdirSync(taskDir, { recursive: true });
3641
3619
  for (const basename2 of listTemplateFiles()) {
3642
3620
  const source = resolveTaskTemplateSource(basename2) ?? path14.join(templatesRoot(), basename2);
3643
- fs13.writeFileSync(path14.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
3621
+ fs14.writeFileSync(path14.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
3644
3622
  }
3645
3623
  const statusPath = path14.join(taskDir, "status.json");
3646
3624
  const status = readJsonFile(statusPath);
@@ -3663,13 +3641,13 @@ function derivePhase(status) {
3663
3641
  }
3664
3642
  function taskList() {
3665
3643
  const root = tasksRoot();
3666
- if (!fs13.existsSync(root)) {
3644
+ if (!fs14.existsSync(root)) {
3667
3645
  console.log("No tasks found.");
3668
3646
  return;
3669
3647
  }
3670
3648
  const rows = [];
3671
3649
  let invalidCount = 0;
3672
- for (const entry of fs13.readdirSync(root).sort()) {
3650
+ for (const entry of fs14.readdirSync(root).sort()) {
3673
3651
  if (entry === "_archive" || entry === "_templates") continue;
3674
3652
  if (isOrphanedWorktreeState(entry)) {
3675
3653
  invalidCount += 1;
@@ -3687,7 +3665,7 @@ function taskList() {
3687
3665
  continue;
3688
3666
  }
3689
3667
  const statusPath = path14.join(taskDirForCwd(process.cwd(), entry), "status.json");
3690
- if (!fs13.existsSync(statusPath)) continue;
3668
+ if (!fs14.existsSync(statusPath)) continue;
3691
3669
  try {
3692
3670
  const status = readJsonFile(statusPath);
3693
3671
  const phase = derivePhase(status);
@@ -3724,7 +3702,7 @@ function taskStatus(id) {
3724
3702
  validateTaskId(id);
3725
3703
  const cwd = resolveTaskCwd(id);
3726
3704
  const statusPath = taskStatusFileForCwd(cwd, id);
3727
- if (!fs13.existsSync(statusPath)) {
3705
+ if (!fs14.existsSync(statusPath)) {
3728
3706
  throw new Error(`Error: No status.json found for task ${id}`);
3729
3707
  }
3730
3708
  const status = readJsonFile(statusPath);
@@ -3797,7 +3775,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
3797
3775
  assertValidVerdict(phaseArg, verdictArg);
3798
3776
  const taskCwd = resolveTaskCwd(id);
3799
3777
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3800
- if (!fs13.existsSync(statusPath)) {
3778
+ if (!fs14.existsSync(statusPath)) {
3801
3779
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3802
3780
  }
3803
3781
  const status = readJsonFile(statusPath);
@@ -3863,7 +3841,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3863
3841
  for (const id of ids) {
3864
3842
  const taskCwd = resolveTaskCwd(id);
3865
3843
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3866
- if (!fs13.existsSync(statusPath)) {
3844
+ if (!fs14.existsSync(statusPath)) {
3867
3845
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3868
3846
  }
3869
3847
  const status = readJsonFile(statusPath);
@@ -3965,7 +3943,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3965
3943
  const originalSnapshots2 = /* @__PURE__ */ new Map();
3966
3944
  for (const ctx of ctxByTask.values()) {
3967
3945
  try {
3968
- originalSnapshots2.set(ctx.statusPath, fs13.readFileSync(ctx.statusPath, "utf8"));
3946
+ originalSnapshots2.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
3969
3947
  } catch (error) {
3970
3948
  const message = error instanceof Error ? error.message : String(error);
3971
3949
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -4002,8 +3980,8 @@ function taskAccept(ids, phaseArg, options = {}) {
4002
3980
  if (original === void 0) continue;
4003
3981
  try {
4004
3982
  const tmpFile = `${filePath}.rollback.tmp`;
4005
- fs13.writeFileSync(tmpFile, original, "utf8");
4006
- fs13.renameSync(tmpFile, filePath);
3983
+ fs14.writeFileSync(tmpFile, original, "utf8");
3984
+ fs14.renameSync(tmpFile, filePath);
4007
3985
  } catch (rollbackErr) {
4008
3986
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
4009
3987
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -4027,12 +4005,12 @@ ${rollbackErrors.join("\n")}`
4027
4005
  const bundleNote = ids.length > 1 ? ` Bundle: ${ids.join(", ")}.` : "";
4028
4006
  const noteLine = `[${today()}] Operator accepted ${phaseArg} via \`canon task accept\` \u2014 ${sanctioned ? "sanctioned (agent verdict overridden)" : "unblocked (advancing verdict preserved)"}. Reason: ${reason}.${bundleNote}`;
4029
4007
  try {
4030
- if (fs13.existsSync(notesPath)) {
4031
- fs13.appendFileSync(notesPath, `
4008
+ if (fs14.existsSync(notesPath)) {
4009
+ fs14.appendFileSync(notesPath, `
4032
4010
  ${noteLine}
4033
4011
  `, "utf8");
4034
4012
  } else {
4035
- fs13.writeFileSync(notesPath, `${noteLine}
4013
+ fs14.writeFileSync(notesPath, `${noteLine}
4036
4014
  `, "utf8");
4037
4015
  }
4038
4016
  } catch (error) {
@@ -4155,7 +4133,7 @@ ${noteLine}
4155
4133
  const originalSnapshots = /* @__PURE__ */ new Map();
4156
4134
  for (const ctx of ctxByTask.values()) {
4157
4135
  try {
4158
- originalSnapshots.set(ctx.statusPath, fs13.readFileSync(ctx.statusPath, "utf8"));
4136
+ originalSnapshots.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
4159
4137
  } catch (error) {
4160
4138
  const message = error instanceof Error ? error.message : String(error);
4161
4139
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -4180,8 +4158,8 @@ ${noteLine}
4180
4158
  if (original === void 0) continue;
4181
4159
  try {
4182
4160
  const tmpFile = `${filePath}.rollback.tmp`;
4183
- fs13.writeFileSync(tmpFile, original, "utf8");
4184
- fs13.renameSync(tmpFile, filePath);
4161
+ fs14.writeFileSync(tmpFile, original, "utf8");
4162
+ fs14.renameSync(tmpFile, filePath);
4185
4163
  } catch (rollbackErr) {
4186
4164
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
4187
4165
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -4203,12 +4181,12 @@ ${noteLine}
4203
4181
  const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4204
4182
  const noteLine = `[${today()}] Operator accepted implement phase via \`canon task accept\` \u2014 auto-commit will be skipped.${options.force ? " (--force)" : ""}`;
4205
4183
  try {
4206
- if (fs13.existsSync(notesPath)) {
4207
- fs13.appendFileSync(notesPath, `
4184
+ if (fs14.existsSync(notesPath)) {
4185
+ fs14.appendFileSync(notesPath, `
4208
4186
  ${noteLine}
4209
4187
  `, "utf8");
4210
4188
  } else {
4211
- fs13.writeFileSync(notesPath, `${noteLine}
4189
+ fs14.writeFileSync(notesPath, `${noteLine}
4212
4190
  `, "utf8");
4213
4191
  }
4214
4192
  } catch (error) {
@@ -4228,14 +4206,14 @@ function taskResetSpecReview(id) {
4228
4206
  const taskCwd = resolveTaskCwd(id);
4229
4207
  const taskDir = taskDirForCwd(taskCwd, id);
4230
4208
  const statusPath = path14.join(taskDir, "status.json");
4231
- if (!fs13.existsSync(statusPath)) {
4209
+ if (!fs14.existsSync(statusPath)) {
4232
4210
  throw new Error(`Error: no status.json at ${statusPath}`);
4233
4211
  }
4234
4212
  const reviewPath = path14.join(taskDir, "spec-review.md");
4235
- if (fs13.existsSync(reviewPath)) {
4213
+ if (fs14.existsSync(reviewPath)) {
4236
4214
  let n = 1;
4237
- while (fs13.existsSync(path14.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4238
- fs13.renameSync(reviewPath, path14.join(taskDir, `spec-review-prior-${n}.md`));
4215
+ while (fs14.existsSync(path14.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4216
+ fs14.renameSync(reviewPath, path14.join(taskDir, `spec-review-prior-${n}.md`));
4239
4217
  console.log(`Archived prior spec-review.md \u2192 spec-review-prior-${n}.md`);
4240
4218
  }
4241
4219
  const status = readJsonFile(statusPath);
@@ -4259,7 +4237,7 @@ function taskResetCodeReview(id) {
4259
4237
  const taskCwd = resolveTaskCwd(id);
4260
4238
  const taskDir = taskDirForCwd(taskCwd, id);
4261
4239
  const statusPath = path14.join(taskDir, "status.json");
4262
- if (!fs13.existsSync(statusPath)) {
4240
+ if (!fs14.existsSync(statusPath)) {
4263
4241
  throw new Error(`Error: no status.json at ${statusPath}`);
4264
4242
  }
4265
4243
  const status = readJsonFile(statusPath);
@@ -4345,12 +4323,12 @@ function resolveMainCheckoutRoot() {
4345
4323
  }
4346
4324
  function safeRealpath(target) {
4347
4325
  try {
4348
- return fs13.realpathSync(target);
4326
+ return fs14.realpathSync(target);
4349
4327
  } catch {
4350
4328
  const parent = path14.dirname(target);
4351
4329
  if (parent === target) return target;
4352
4330
  try {
4353
- return path14.join(fs13.realpathSync(parent), path14.basename(target));
4331
+ return path14.join(fs14.realpathSync(parent), path14.basename(target));
4354
4332
  } catch {
4355
4333
  return target;
4356
4334
  }
@@ -4458,12 +4436,12 @@ function taskPostMergeSync(branchArg) {
4458
4436
  }
4459
4437
  function nudgeShippableTasks() {
4460
4438
  const root = tasksRoot();
4461
- if (!fs13.existsSync(root)) return;
4439
+ if (!fs14.existsSync(root)) return;
4462
4440
  const shippable = [];
4463
- for (const entry of fs13.readdirSync(root).sort()) {
4441
+ for (const entry of fs14.readdirSync(root).sort()) {
4464
4442
  if (entry === "_archive" || entry.startsWith("_")) continue;
4465
4443
  const statusPath = path14.join(root, entry, "status.json");
4466
- if (!fs13.existsSync(statusPath)) continue;
4444
+ if (!fs14.existsSync(statusPath)) continue;
4467
4445
  let status;
4468
4446
  try {
4469
4447
  status = readJsonFile(statusPath);
@@ -4538,7 +4516,7 @@ function taskSet(args2) {
4538
4516
  validateTaskId(id);
4539
4517
  const taskCwd = resolveTaskCwd(id);
4540
4518
  const statusPath = taskStatusFileForCwd(taskCwd, id);
4541
- if (!fs13.existsSync(statusPath)) {
4519
+ if (!fs14.existsSync(statusPath)) {
4542
4520
  throw new Error(`Error: No status.json found for task ${id}`);
4543
4521
  }
4544
4522
  const status = readJsonFile(statusPath);
@@ -4879,7 +4857,7 @@ function currentPinFromManifest(manifest) {
4879
4857
  return "unknown";
4880
4858
  }
4881
4859
  function bakedVersion() {
4882
- return "3.3.1";
4860
+ return "3.5.0";
4883
4861
  }
4884
4862
  function ownPackageName(pkgDir) {
4885
4863
  try {
@@ -5355,7 +5333,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5355
5333
  cutoverWarnings.push(docsRefsCheckRel);
5356
5334
  }
5357
5335
  const versionPath = join5(cwd, ".canon", "version");
5358
- const newVersion = "3.3.1";
5336
+ const newVersion = "3.5.0";
5359
5337
  const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
5360
5338
  if (currentVersion !== newVersion) {
5361
5339
  pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
@@ -5663,7 +5641,7 @@ Global:
5663
5641
  `);
5664
5642
  }
5665
5643
  function printVersion() {
5666
- console.log("3.3.1");
5644
+ console.log("3.5.0");
5667
5645
  }
5668
5646
  switch (command) {
5669
5647
  case "doctor":