canon-ai 3.11.0 → 3.13.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,38 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.13.0] — 2026-10-23
8
+
9
+ ### Added
10
+
11
+ - **Bug-fix tasks record red-first evidence in the handoff, and code review checks for it.** The handoff template has a Red-First Evidence section with one row per red-first acceptance criterion: the test command, the commit the pre-fix code was at, the failure it showed there, and the post-fix result. A `[wrong-premise]` blocker can stand in when the test cannot be made to fail for the reason the spec states. The implement prompts point at the section and say which pre-fix state to run against. The anchored code-review lens flags a red-first criterion with no pre-fix run as a spec-compliance gap, and a revision round can close the gap by running the test against the pre-fix commit without redoing the fix.
12
+ - **Claude sub-agents get their own metrics rows.** When a pipeline phase's Claude session spawns sub-agents, each top-level sub-agent gets a row with Agent `claude:<agent type>`, its duration, and its own cumulative tokens. For `code_review` that separates the anchored and cold reviewers from the foreman. The session's own row is unchanged.
13
+
14
+ ### Fixed
15
+
16
+ - **The spec_gap halt's fix hint names the amendment heading the next `--reroute` requires.** After a task's first reroute, it says `## Amendment Round N` instead of a bare `## Amendment` that `--reroute` refuses.
17
+ - **Implement auto-commit no longer fails on a gitignored generated file listed beside its generator.** The ignored file is not passed to `git add`. If staging fails partway, the index is restored to its state before the attempt.
18
+ - **A handoff row for one of the task's own artifacts no longer fails the coverage checks** when the pipeline rewrote it identical to the base. Another bundle member's artifact is still rejected.
19
+ - **The scaffold commit no longer fails when a telemetry file was removed with `git rm`**, and a committed rename's old path counts as deletion evidence at implement close.
20
+ - **The base-divergence and orphaned-branch messages name the actual branch** instead of a literal `<base>`, quoted when the name needs it, so the printed `git push` and `gh pr create` commands can be pasted as is.
21
+
22
+ ## [3.12.0] — 2026-10-21
23
+
24
+ ### Added
25
+
26
+ - **Broken references in a task's handoff or QA summary are fixed by the agent that wrote them, before the commit.** After implement, and again after QA, `canon run` checks the task's own artifacts with the docs-refs checker. If it finds a broken reference, it resumes the same agent session to fix it, up to two attempts. A fix session may change only the task's own artifacts; if it touches anything else, the run stops and names the files. If a reference is still broken after two attempts, canon warns and continues as before. The implement and QA prompts also now ask agents to cite repository files by full path and deleted files as markdown links.
27
+ - **`--full-send` pushes its own scaffold commits to the base branch before opening the draft PR.** It pushes only commits that have the exact scaffold subject and change only task artifacts and pipeline telemetry, and it pushes the checked commit rather than the moving branch. If any other commit is unpushed on the base branch, nothing is pushed and the run stops at the divergence check as before. A rejected push (for example, a protected branch) stops with a hint to push or merge manually. The "Scaffold committed" hint now prints only when such commits exist.
28
+
29
+ ### Changed
30
+
31
+ - **`--pr` and `--push` block only on real merge conflicts with the base.** A base branch that moved on files the task never touched, such as another task's artifacts, no longer stops the run; canon prints one line saying those files merge cleanly. A conflict with `origin/<base>`, including in a file the spec declared, still aborts before the push and names the conflicted files. The task's own out-of-scope changes still abort as before. `--force` bypasses a conflict with a warning. On Git older than 2.38, canon warns that conflicts weren't checked and proceeds.
32
+
33
+ ### Fixed
34
+
35
+ - **`canon watch` reports a full-send run that died after QA as a death, not a clean stop.** A run that crashed while opening its draft PR now shows `reason=death` and exits 4, with the reason on stderr, whether watch was attached during the run or started after it. A tail that is still running keeps watch blocking.
36
+ - **`canon task reset-spec-review` re-arms the spec gate.** The gate disarms itself when it halts, so a reset after that halt used to let the next run go straight through to human review. The reset now re-arms it and says so, unless the task is full-send.
37
+ - **`--ship` no longer warns that the branch delete failed.** It merges with `gh pr merge --squash`, deletes the remote branch itself, and deletes the local branch after the worktree is removed; the old order made `gh` try to delete a branch the worktree still held.
38
+
7
39
  ## [3.11.0] — 2026-10-20
8
40
 
9
41
  ### Changed
package/README.md CHANGED
@@ -257,7 +257,7 @@ Pipeline sessions are headless, so nobody is at the keyboard to answer a permiss
257
257
  - **Worktrees separate branch state by convention, not by enforcement.** Each task, or each bundle when you pass several task ids together, gets its own worktree and branch, and the pipeline's dirty-tree gates inspect the active worktree at phase boundaries. But Claude sessions are also given the main checkout via `--add-dir` and run with permissions skipped, so nothing stops a session from writing there, and an unrelated file dropped into the main checkout is not something those gates look for; tasks with `worktree: false` run in the main checkout outright. Validation commands run with your normal privileges.
258
258
  - **Budget caps bound cost, not access.** Each headless Claude phase runs with `--max-budget-usd`; hitting the cap stops the session. It does not narrow what the session may do beforehand, and interactive sessions (`--interactive`) run uncapped.
259
259
  - **Your code goes to the model providers.** Each agent phase sends prompts and repository context to the provider running it, Anthropic for Claude phases and OpenAI for Codex phases, with `code_review` using both, all through the Claude Code and Codex CLIs under whatever data terms your accounts have. Treat a `canon run` like any other agent session in that respect.
260
- - **Git side effects need a flag.** The pipeline never pushes, opens PRs, merges, or publishes on its own. `--pr` pushes the task branch and opens a draft PR, stopping at `human_review`. `--ship` squash-merges that PR; it checks the task's phase, not whether anyone approved, so PR approval is your prerequisite (or your branch protection's), not something canon enforces. `--full-send` skips the human spec gate and opens the draft PR on clean QA, still stopping before merge; on a `delicate` task it refuses without `--force`.
260
+ - **Git side effects need a flag.** The pipeline never pushes, opens PRs, merges, or publishes on its own. `--pr` pushes the task branch and opens a draft PR, stopping at `human_review`. `--ship` squash-merges that PR; it checks the task's phase, not whether anyone approved, so PR approval is your prerequisite (or your branch protection's), not something canon enforces. `--full-send` skips the human spec gate and opens the draft PR on clean QA, still stopping before merge; its tail also pushes unpushed base commits that carry only task artifacts and pipeline telemetry, and leaves other unpushed commits to the divergence gate unless `--allow-divergent-base` is set. On a `delicate` task it refuses without `--force`.
261
261
 
262
262
  Run canon on repositories where you would be comfortable running your test suite unattended. If that is not your situation, keep it to a machine or container scoped for the purpose. Replacing the blanket permission skip with a narrower confinement is on the roadmap; the constraint to solve is that adopter validation commands legitimately need network and system access the sandbox would block. Security reports go through [SECURITY.md](SECURITY.md).
263
263
 
package/dist/cli/index.js CHANGED
@@ -403,6 +403,11 @@ function filterGitIgnoredPaths(paths, cwd) {
403
403
  const stdout = result.stdout ?? "";
404
404
  return new Set(stdout.split("\0").filter((p) => p.length > 0));
405
405
  }
406
+ var CANON_TASK_ID_PATTERN = "[a-z0-9][a-z0-9._-]*";
407
+ var SCAFFOLD_ARTIFACTS_SUBJECT = new RegExp(`^task\\(${CANON_TASK_ID_PATTERN}\\): commit artifacts pre-pipeline$`);
408
+ var SCAFFOLD_TELEMETRY_SUBJECT = new RegExp(
409
+ `^chore: absorb pre-implement telemetry into scaffold for ${CANON_TASK_ID_PATTERN}(?:, ${CANON_TASK_ID_PATTERN})*$`
410
+ );
406
411
 
407
412
  // src/orchestrator/worktree.ts
408
413
  var PIPELINE_TELEMETRY_FILES = [
@@ -1500,7 +1505,7 @@ function checkTemplates(cwd) {
1500
1505
  }
1501
1506
  function checkCanonVersion(cwd) {
1502
1507
  const versionPath = join(cwd, ".canon", "version");
1503
- const installedVersion = "3.11.0";
1508
+ const installedVersion = "3.13.0";
1504
1509
  if (!existsSync(versionPath)) {
1505
1510
  return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
1506
1511
  }
@@ -2025,7 +2030,7 @@ function initCmd(_args) {
2025
2030
  }
2026
2031
  function writeCanonVersion(cwd) {
2027
2032
  const versionPath = join2(cwd, ".canon", "version");
2028
- const version = "3.11.0";
2033
+ const version = "3.13.0";
2029
2034
  mkdirSync(dirname2(versionPath), { recursive: true });
2030
2035
  writeFileSync(versionPath, version + "\n");
2031
2036
  }
@@ -2382,6 +2387,9 @@ function findFirstBlockedPhase(status) {
2382
2387
  }
2383
2388
  return null;
2384
2389
  }
2390
+ function isFullSendTailPending(status) {
2391
+ return status.full_send === true && status.status === "human_review" && (status.phases.human_review?.status ?? "pending") === "pending";
2392
+ }
2385
2393
  function findPreviousDonePhase(status, beforePhase) {
2386
2394
  const index = PHASE_ORDER.indexOf(beforePhase);
2387
2395
  for (let i = index - 1; i >= 0; i -= 1) {
@@ -2518,6 +2526,8 @@ function parseWatchArgs(args2) {
2518
2526
  }
2519
2527
  function summarizeIdle(result) {
2520
2528
  switch (result.kind) {
2529
+ case "live":
2530
+ throw new Error("summarizeIdle called for live result");
2521
2531
  case "checkpoint":
2522
2532
  return { state: result.state, reason: "checkpoint", phase: result.phase, verdict: result.verdict, pid: result.pid };
2523
2533
  case "complete":
@@ -2572,6 +2582,15 @@ function classifyAttach(ctx, taskId, probeAlive, now) {
2572
2582
  return { kind: "launch_window", state };
2573
2583
  }
2574
2584
  if (status) {
2585
+ if (isFullSendTailPending(status)) {
2586
+ if (orchestratorLive && ctx.resolvedPid != null) {
2587
+ return { kind: "live", pid: ctx.resolvedPid, state };
2588
+ }
2589
+ const heartbeatPrimary = ctx.heartbeatResult.kind === "found" ? ctx.heartbeatResult.record.task_ids[0] : void 0;
2590
+ const primaryId = typeof heartbeatPrimary === "string" && heartbeatPrimary.length > 0 ? heartbeatPrimary : taskId;
2591
+ const reason = findCrashEvidence(ctx, taskId, probeAlive, primaryId, orchestratorLive);
2592
+ if (reason) return { kind: "death", state, hint: `run \`canon run ${taskId}\` to resume`, reason };
2593
+ }
2575
2594
  const currentPhase = PHASE_ORDER.find((phase) => (status.phases[phase]?.status ?? "pending") !== "done");
2576
2595
  const currentStatus = currentPhase ? status.phases[currentPhase]?.status ?? "pending" : null;
2577
2596
  if (currentPhase && currentPhase !== "human_review" && (currentStatus === "pending" || currentStatus === "in_progress")) {
@@ -2634,6 +2653,13 @@ function classifyIdle(ctx, taskId, probeAlive = () => false, now = Date.now(), p
2634
2653
  return { kind: "auto_block", state: "blocked", phase: blockedPhase, pid: ctx.resolvedPid ?? void 0 };
2635
2654
  }
2636
2655
  if (state === "human_review") {
2656
+ if (isFullSendTailPending(status)) {
2657
+ if (orchestratorLive && ctx.resolvedPid != null) {
2658
+ return { kind: "live", state, pid: ctx.resolvedPid };
2659
+ }
2660
+ const crashReason = findCrashEvidence(ctx, taskId, probeAlive, primaryTaskId, orchestratorLive);
2661
+ if (crashReason) return { kind: "death", state, pid: ctx.resolvedPid ?? void 0, reason: crashReason };
2662
+ }
2637
2663
  const verdict = status.phases.code_review?.verdict || void 0;
2638
2664
  return {
2639
2665
  kind: "checkpoint",
@@ -2689,6 +2715,7 @@ function orchestratorStillProgressing(ctx, probeAlive, now = Date.now()) {
2689
2715
  const status = ctx.statusResult.kind === "ok" && isStatusJson(ctx.statusResult.status) ? ctx.statusResult.status : null;
2690
2716
  if (status == null) return false;
2691
2717
  if (findFirstBlockedPhase(status) && !blockedPhaseLivenessTrusted(ctx, orchestratorLive, now)) return false;
2718
+ if (isFullSendTailPending(status)) return true;
2692
2719
  const state = status.status;
2693
2720
  return state !== "human_review" && state !== "complete";
2694
2721
  }
@@ -3007,6 +3034,11 @@ function watchCmd(args2, deps = {}) {
3007
3034
  return exit(0);
3008
3035
  }
3009
3036
  const idleResult = classifyIdle(freshCtx, taskId, isOrchestratorAlive, now(), rememberedPrimaryTaskId ?? void 0);
3037
+ if (idleResult.kind === "live") {
3038
+ reportPhasePointer(freshCtx);
3039
+ if (parsed.follow) tailRunLog(freshCtx, taskId, { stderr }, tailState, rememberedPrimaryTaskId ?? void 0);
3040
+ continue;
3041
+ }
3010
3042
  if (idleResult.kind === "death" && idleResult.reason) {
3011
3043
  stderr(`canon watch: ${idleResult.reason}; run \`canon run ${taskId}\` to resume
3012
3044
  `);
@@ -3036,7 +3068,7 @@ function watchCmd(args2, deps = {}) {
3036
3068
  }
3037
3069
 
3038
3070
  // src/task/index.ts
3039
- import { spawnSync as spawnSync8 } from "child_process";
3071
+ import { spawnSync as spawnSync9 } from "child_process";
3040
3072
  import fs16 from "fs";
3041
3073
  import path16 from "path";
3042
3074
 
@@ -3049,7 +3081,7 @@ function isInstalledSourcePath(sourcePath) {
3049
3081
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
3050
3082
  }
3051
3083
  function resolveCanonVersion(explicit) {
3052
- return explicit ?? "3.11.0" ?? "dev";
3084
+ return explicit ?? "3.13.0" ?? "dev";
3053
3085
  }
3054
3086
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
3055
3087
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
@@ -3183,6 +3215,7 @@ function isPristineTaskArtifact(content, basename2, taskId, title) {
3183
3215
  }
3184
3216
 
3185
3217
  // src/orchestrator/validation.ts
3218
+ import { spawnSync as spawnSync8 } from "child_process";
3186
3219
  import fs14 from "fs";
3187
3220
  import path14 from "path";
3188
3221
  function computeLatestValidationResults(handoffContent) {
@@ -3683,6 +3716,7 @@ function verifyHandoffAgainstDiffFromData(taskIds, inputs) {
3683
3716
  const files = handoffFilesByTask.get(taskId) ?? [];
3684
3717
  for (const filePath of files) {
3685
3718
  if (gitIgnored.has(filePath)) continue;
3719
+ if (isPipelineOwnedTaskArtifact(filePath, [taskId])) continue;
3686
3720
  if (!coveredPaths.has(filePath)) {
3687
3721
  issues.push(`[${taskId}] handoff\u2192diff: ${filePath} listed in handoff but not in diff \u2014 it has no committed change against the task base. If it was untouched or rewritten with identical content, drop the row`);
3688
3722
  }
@@ -3852,13 +3886,13 @@ function writeStatusAtomic(filePath, status) {
3852
3886
  }
3853
3887
  function runGit(args2, options = {}) {
3854
3888
  if (options.stdio === "inherit") {
3855
- return spawnSync8("git", args2, {
3889
+ return spawnSync9("git", args2, {
3856
3890
  cwd: options.cwd ?? process.cwd(),
3857
3891
  encoding: "utf8",
3858
3892
  stdio: "inherit"
3859
3893
  });
3860
3894
  }
3861
- return spawnSync8("git", args2, {
3895
+ return spawnSync9("git", args2, {
3862
3896
  cwd: options.cwd ?? process.cwd(),
3863
3897
  encoding: "utf8",
3864
3898
  stdio: ["ignore", "pipe", "pipe"]
@@ -4562,9 +4596,16 @@ function taskResetSpecReview(id) {
4562
4596
  if (status.sessions && Object.hasOwn(status.sessions, "claude_spec")) {
4563
4597
  delete status.sessions.claude_spec;
4564
4598
  }
4599
+ const rearmGate = status.full_send !== true && status.human_spec_gate !== true;
4600
+ if (rearmGate) status.human_spec_gate = true;
4565
4601
  status.updated = today();
4566
4602
  writeStatusAtomic(statusPath, status);
4567
4603
  console.log(`Reset ${id}: spec \u2192 done, spec_review \u2192 pending (iter=0, verdict cleared, claude_spec session dropped)`);
4604
+ if (rearmGate) {
4605
+ console.log(
4606
+ `Re-armed the spec gate: the next \`canon run ${id}\` stops after spec_review approves. To continue past it instead, run \`canon task set ${id} human_spec_gate false\` first.`
4607
+ );
4608
+ }
4568
4609
  }
4569
4610
  function taskResetCodeReview(id) {
4570
4611
  if (!id) throw new Error("Error: usage: canon task reset-code-review <TASK-ID>");
@@ -4615,7 +4656,7 @@ function taskResetCodeReview(id) {
4615
4656
  );
4616
4657
  }
4617
4658
  function ensureGitAvailable() {
4618
- const result = spawnSync8("git", ["--version"], { stdio: "ignore" });
4659
+ const result = spawnSync9("git", ["--version"], { stdio: "ignore" });
4619
4660
  if (result.error || result.status !== 0) {
4620
4661
  throw new Error("Error: git is required.");
4621
4662
  }
@@ -4996,7 +5037,7 @@ function taskCmd2(args2) {
4996
5037
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, realpathSync as realpathSync2, writeFileSync as writeFileSync2 } from "fs";
4997
5038
  import { fileURLToPath as fileURLToPath4 } from "url";
4998
5039
  import { dirname as dirname4, join as join4 } from "path";
4999
- import { spawnSync as spawnSync9 } from "child_process";
5040
+ import { spawnSync as spawnSync10 } from "child_process";
5000
5041
  var packageDir3 = join4(dirname4(fileURLToPath4(import.meta.url)), "../..");
5001
5042
  function detectInstallType(pkgDirOverride) {
5002
5043
  const dir = pkgDirOverride ?? packageDir3;
@@ -5049,7 +5090,7 @@ function nonInteractiveSshCommand(configuredCommand) {
5049
5090
  return `${command2} -oBatchMode=yes`;
5050
5091
  }
5051
5092
  function defaultGitRunner(args2) {
5052
- const result = spawnSync9("git", args2, {
5093
+ const result = spawnSync10("git", args2, {
5053
5094
  encoding: "utf8",
5054
5095
  timeout: GIT_RESOLUTION_TIMEOUT_MS,
5055
5096
  env: {
@@ -5234,7 +5275,7 @@ function currentPinFromManifest(manifest) {
5234
5275
  return "unknown";
5235
5276
  }
5236
5277
  function bakedVersion() {
5237
- return "3.11.0";
5278
+ return "3.13.0";
5238
5279
  }
5239
5280
  function ownPackageName(pkgDir) {
5240
5281
  try {
@@ -5246,7 +5287,7 @@ function ownPackageName(pkgDir) {
5246
5287
  }
5247
5288
  var NPM_VIEW_TIMEOUT_MS = 3e4;
5248
5289
  function defaultNpmViewRunner(args2, cwd) {
5249
- const result = spawnSync9("npm", args2, { encoding: "utf8", timeout: NPM_VIEW_TIMEOUT_MS, ...cwd ? { cwd } : {} });
5290
+ const result = spawnSync10("npm", args2, { encoding: "utf8", timeout: NPM_VIEW_TIMEOUT_MS, ...cwd ? { cwd } : {} });
5250
5291
  if (result.error) return { status: null, stdout: "", stderr: result.error.message };
5251
5292
  return { status: result.status, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
5252
5293
  }
@@ -5302,7 +5343,7 @@ function updateCmd(args2, deps = {}) {
5302
5343
  const pkgDir = deps.packageDir ?? packageDir3;
5303
5344
  const pkgName = ownPackageName(pkgDir);
5304
5345
  const cwd = deps.cwd ?? process.cwd();
5305
- const spawn2 = deps.spawnRunner ?? ((cmd, cmdArgs, opts) => spawnSync9(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }));
5346
+ const spawn2 = deps.spawnRunner ?? ((cmd, cmdArgs, opts) => spawnSync10(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }));
5306
5347
  const runGit2 = deps.gitRunner ?? defaultGitRunner;
5307
5348
  const nowIso = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
5308
5349
  let options;
@@ -5433,7 +5474,7 @@ function updateCmd(args2, deps = {}) {
5433
5474
  import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
5434
5475
  import { fileURLToPath as fileURLToPath5 } from "url";
5435
5476
  import { basename, dirname as dirname5, join as join5, relative as relative3, resolve } from "path";
5436
- import { spawnSync as spawnSync10 } from "child_process";
5477
+ import { spawnSync as spawnSync11 } from "child_process";
5437
5478
 
5438
5479
  // src/lib/canon-owned.ts
5439
5480
  var CANON_OWNED = [
@@ -5542,7 +5583,7 @@ function classifyDestinations(cwd, relPaths) {
5542
5583
  const classes = /* @__PURE__ */ new Map();
5543
5584
  const uniqueRelPaths = [...new Set(relPaths)];
5544
5585
  if (uniqueRelPaths.length === 0) return classes;
5545
- const probe = spawnSync10("git", ["rev-parse", "--is-inside-work-tree"], {
5586
+ const probe = spawnSync11("git", ["rev-parse", "--is-inside-work-tree"], {
5546
5587
  cwd,
5547
5588
  encoding: "utf8",
5548
5589
  stdio: ["ignore", "pipe", "ignore"]
@@ -5554,12 +5595,12 @@ function classifyDestinations(cwd, relPaths) {
5554
5595
  }
5555
5596
  return classes;
5556
5597
  }
5557
- const lsFiles = spawnSync10("git", ["ls-files", "-z", "--", ...uniqueRelPaths], {
5598
+ const lsFiles = spawnSync11("git", ["ls-files", "-z", "--", ...uniqueRelPaths], {
5558
5599
  cwd,
5559
5600
  encoding: "utf8",
5560
5601
  stdio: ["ignore", "pipe", "ignore"]
5561
5602
  });
5562
- const status = spawnSync10("git", ["status", "--porcelain=v1", "-z", "--", ...uniqueRelPaths], {
5603
+ const status = spawnSync11("git", ["status", "--porcelain=v1", "-z", "--", ...uniqueRelPaths], {
5563
5604
  cwd,
5564
5605
  encoding: "utf8",
5565
5606
  stdio: ["ignore", "pipe", "ignore"]
@@ -5711,7 +5752,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5711
5752
  cutoverWarnings.push(docsRefsCheckRel);
5712
5753
  }
5713
5754
  const versionPath = join5(cwd, ".canon", "version");
5714
- const newVersion = "3.11.0";
5755
+ const newVersion = "3.13.0";
5715
5756
  const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
5716
5757
  if (currentVersion !== newVersion) {
5717
5758
  pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
@@ -5864,7 +5905,7 @@ function upgradeCmd(args2) {
5864
5905
  process.exit(2);
5865
5906
  }
5866
5907
  if (upgraded.length > 0 && !options.noStage) {
5867
- const r = spawnSync10("git", ["add", ...upgraded], { cwd: process.cwd(), stdio: "inherit" });
5908
+ const r = spawnSync11("git", ["add", ...upgraded], { cwd: process.cwd(), stdio: "inherit" });
5868
5909
  if (r.status !== 0) {
5869
5910
  console.error("\nwarning: failed to stage changes \u2014 run `git add` manually.");
5870
5911
  }
@@ -5992,14 +6033,17 @@ canon run options:
5992
6033
  protected/configured docs listed in spec.md's "### Affected Files"
5993
6034
  table (all admitted docs once QA is done). Dirty files outside
5994
6035
  that set die with a remediation message.
5995
- Aborts if HEAD's tree differs from origin/<base> on files not in
5996
- spec's Affected Files (bypass with --force).
6036
+ Aborts if merging origin/<base> conflicts, or branch changes files
6037
+ outside spec's Affected Files (bypass with --force).
5997
6038
  --push At human_review: push branch only, no PR (requires gh). Same allow-list
5998
- as --pr. Aborts if HEAD's tree differs from origin/<base> on files not
5999
- in spec's Affected Files (bypass with --force).
6039
+ as --pr. Aborts if merging origin/<base> conflicts, or branch changes
6040
+ files outside spec's Affected Files (bypass with --force).
6000
6041
  --full-send Skip the spec gate and auto-open a draft PR after clean QA
6001
- --force Acknowledge high-commitment combinations (currently: --full-send on a delicate task)
6002
- --ship Merge the open PR (calls gh pr merge --squash --delete-branch), tear
6042
+ --force Acknowledge high-commitment combinations and bypass
6043
+ explicit safety gates where documented (currently:
6044
+ --full-send on delicate tasks, reroute amendment gate,
6045
+ base-drift gate, and dirty REPO_ROOT worktree-start gate)
6046
+ --ship Merge the open PR (gh pr merge --squash), delete its remote branch, tear
6003
6047
  down the worktree, archive the task dir, and pull the base branch. Run
6004
6048
  after the PR is approved \u2014 do NOT merge the PR manually first. If you
6005
6049
  already merged externally, --ship detects the merged state and resumes
@@ -6020,7 +6064,7 @@ Global:
6020
6064
  `);
6021
6065
  }
6022
6066
  function printVersion() {
6023
- console.log("3.11.0");
6067
+ console.log("3.13.0");
6024
6068
  }
6025
6069
  switch (command) {
6026
6070
  case "doctor":