canon-ai 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -1207,8 +1207,8 @@ ${RECOMMENDED_NUDGE}`
1207
1207
  };
1208
1208
  }
1209
1209
  const mentionsCanon = existingFiles.some((filename) => {
1210
- const path13 = join(cwd, filename);
1211
- return /canon/i.test(readFileSync(path13, "utf8"));
1210
+ const path15 = join(cwd, filename);
1211
+ return /canon/i.test(readFileSync(path15, "utf8"));
1212
1212
  });
1213
1213
  if (mentionsCanon) {
1214
1214
  return { label: "canon discovery nudge", status: "pass" };
@@ -1245,7 +1245,7 @@ function checkTemplates(cwd) {
1245
1245
  }
1246
1246
  function checkCanonVersion(cwd) {
1247
1247
  const versionPath = join(cwd, ".canon", "version");
1248
- const installedVersion = "3.1.0";
1248
+ const installedVersion = "3.1.2";
1249
1249
  if (!existsSync(versionPath)) {
1250
1250
  return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
1251
1251
  }
@@ -1419,10 +1419,10 @@ function checkCodexProjectTrust(cwd) {
1419
1419
  trust_level = "trusted"`
1420
1420
  };
1421
1421
  }
1422
- function readAllowFromSettings(path13) {
1423
- if (!existsSync(path13)) return { allow: /* @__PURE__ */ new Set(), status: "missing" };
1422
+ function readAllowFromSettings(path15) {
1423
+ if (!existsSync(path15)) return { allow: /* @__PURE__ */ new Set(), status: "missing" };
1424
1424
  try {
1425
- const parsed = JSON.parse(readFileSync(path13, "utf8"));
1425
+ const parsed = JSON.parse(readFileSync(path15, "utf8"));
1426
1426
  const raw = parsed?.permissions?.allow;
1427
1427
  const allow = new Set(
1428
1428
  Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : []
@@ -1639,6 +1639,31 @@ import {
1639
1639
  } from "fs";
1640
1640
  import { fileURLToPath as fileURLToPath2 } from "url";
1641
1641
  import { dirname as dirname2, join as join2, relative as relative2 } from "path";
1642
+
1643
+ // src/lib/scaffold-paths.ts
1644
+ import { lstatSync } from "fs";
1645
+ import path7 from "path";
1646
+ function assertNoSymlinkDestinations(root, relativePaths) {
1647
+ for (const relativePath of relativePaths) {
1648
+ let current = root;
1649
+ for (const segment of relativePath.split(/[\\/]/)) {
1650
+ current = path7.join(current, segment);
1651
+ let entry;
1652
+ try {
1653
+ entry = lstatSync(current);
1654
+ } catch (error) {
1655
+ const code = error.code;
1656
+ if (code === "ENOENT" || code === "ENOTDIR") break;
1657
+ throw error;
1658
+ }
1659
+ if (entry.isSymbolicLink()) {
1660
+ throw new Error(`Refusing to write ${relativePath}: ${current} is a symlink. Replace it with a regular file or directory before running canon init or upgrade; --force does not bypass this check.`);
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+
1666
+ // src/cli/commands/init.ts
1642
1667
  var packageDir = join2(dirname2(fileURLToPath2(import.meta.url)), "../..");
1643
1668
  var templatesDir = join2(packageDir, "templates");
1644
1669
  var AGENT_FILES = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md"]);
@@ -1665,6 +1690,7 @@ function walkDir(dir, base = dir) {
1665
1690
  }
1666
1691
  function scaffoldTemplates(cwd, srcTemplatesDir) {
1667
1692
  const templateFiles = walkDir(srcTemplatesDir);
1693
+ assertNoSymlinkDestinations(cwd, templateFiles);
1668
1694
  const scaffolded = [];
1669
1695
  const skipped = [];
1670
1696
  for (const rel of templateFiles) {
@@ -1682,6 +1708,7 @@ function scaffoldTemplates(cwd, srcTemplatesDir) {
1682
1708
  function initCmd(_args) {
1683
1709
  checkDeps();
1684
1710
  const cwd = process.cwd();
1711
+ assertNoSymlinkDestinations(cwd, [".gitignore", ".canon/version"]);
1685
1712
  const { scaffolded, skipped } = scaffoldTemplates(cwd, templatesDir);
1686
1713
  const gitignorePath = join2(cwd, ".gitignore");
1687
1714
  const existingGitignore = existsSync2(gitignorePath) ? readFileSync2(gitignorePath, "utf8") : "";
@@ -1715,7 +1742,7 @@ function initCmd(_args) {
1715
1742
  }
1716
1743
  function writeCanonVersion(cwd) {
1717
1744
  const versionPath = join2(cwd, ".canon", "version");
1718
- const version = "3.1.0";
1745
+ const version = "3.1.2";
1719
1746
  mkdirSync(dirname2(versionPath), { recursive: true });
1720
1747
  writeFileSync(versionPath, version + "\n");
1721
1748
  }
@@ -1755,6 +1782,7 @@ function runCmd(args2) {
1755
1782
 
1756
1783
  // src/cli/commands/watch.ts
1757
1784
  import fs7 from "fs";
1785
+ import { StringDecoder } from "string_decoder";
1758
1786
 
1759
1787
  // src/cli/commands/stop.ts
1760
1788
  import { existsSync as existsSync3 } from "fs";
@@ -2362,25 +2390,39 @@ function primaryLogTaskId(ctx, fallbackTaskId) {
2362
2390
  function tailRunLog(ctx, taskId, deps, tailState) {
2363
2391
  const logTaskDir = tolerantTaskDir(primaryLogTaskId(ctx, taskId));
2364
2392
  const logPath = runLogPathFor(logTaskDir);
2393
+ let fd;
2365
2394
  try {
2366
- const stat = fs7.statSync(logPath);
2367
- if (tailState.position == null) {
2395
+ fd = fs7.openSync(logPath, "r");
2396
+ const stat = fs7.fstatSync(fd);
2397
+ const identity = `${logPath}:${stat.dev}:${stat.ino}`;
2398
+ if (tailState.position == null || tailState.path !== logPath) {
2368
2399
  tailState.position = stat.size;
2400
+ tailState.path = logPath;
2401
+ tailState.identity = identity;
2402
+ tailState.decoder = new StringDecoder("utf8");
2369
2403
  return;
2370
2404
  }
2371
- if (stat.size < tailState.position) {
2405
+ if (identity !== tailState.identity || stat.size < tailState.position) {
2372
2406
  tailState.position = 0;
2407
+ tailState.decoder = new StringDecoder("utf8");
2373
2408
  }
2409
+ tailState.identity = identity;
2374
2410
  if (stat.size === tailState.position) return;
2375
- const content = fs7.readFileSync(logPath, "utf8");
2376
- const chunk = content.slice(tailState.position);
2377
- if (chunk.length > 0) deps.stderr?.(chunk);
2378
- tailState.position = stat.size;
2411
+ const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, stat.size - tailState.position));
2412
+ while (tailState.position < stat.size) {
2413
+ const bytes = fs7.readSync(fd, buffer, 0, Math.min(buffer.length, stat.size - tailState.position), tailState.position);
2414
+ if (bytes === 0) break;
2415
+ tailState.position += bytes;
2416
+ const chunk = tailState.decoder.write(buffer.subarray(0, bytes));
2417
+ if (chunk.length > 0) deps.stderr?.(chunk);
2418
+ }
2379
2419
  } catch (error) {
2380
2420
  const code = getErrnoCode2(error);
2381
2421
  if (code === "ENOENT") return;
2382
2422
  deps.stderr?.(`canon watch: failed to tail ${logPath}: ${errorMessage2(error)}
2383
2423
  `);
2424
+ } finally {
2425
+ if (fd !== void 0) fs7.closeSync(fd);
2384
2426
  }
2385
2427
  }
2386
2428
  function gatherContext(taskId, deps) {
@@ -2441,7 +2483,7 @@ function watchCmd(args2, deps = {}) {
2441
2483
  }
2442
2484
  const taskId = parsed.taskId;
2443
2485
  const timeoutDeadline = parsed.timeoutMs == null ? null : now() + parsed.timeoutMs;
2444
- const tailState = { position: null };
2486
+ const tailState = { position: null, decoder: new StringDecoder("utf8") };
2445
2487
  const withinTimeout = () => timeoutDeadline != null && now() >= timeoutDeadline;
2446
2488
  const remainingTimeoutMs = () => {
2447
2489
  if (timeoutDeadline == null) return null;
@@ -2652,21 +2694,21 @@ function watchCmd(args2, deps = {}) {
2652
2694
 
2653
2695
  // src/task/index.ts
2654
2696
  import { spawnSync as spawnSync7 } from "child_process";
2655
- import fs12 from "fs";
2656
- import path12 from "path";
2697
+ import fs13 from "fs";
2698
+ import path14 from "path";
2657
2699
 
2658
2700
  // src/orchestrator/canon-snapshot.ts
2659
2701
  import { spawnSync as spawnSync6 } from "child_process";
2660
2702
  import fs9 from "fs";
2661
- import path9 from "path";
2703
+ import path10 from "path";
2662
2704
 
2663
2705
  // src/orchestrator/git.ts
2664
2706
  import { spawnSync as spawnSync5 } from "child_process";
2665
- import path8 from "path";
2707
+ import path9 from "path";
2666
2708
 
2667
2709
  // src/orchestrator/worktree.ts
2668
2710
  import fs8 from "fs";
2669
- import path7 from "path";
2711
+ import path8 from "path";
2670
2712
  var PIPELINE_TELEMETRY_FILES = [
2671
2713
  "docs/pipeline-invocations.md",
2672
2714
  "docs/task-quality-log.md",
@@ -2709,18 +2751,18 @@ function isInstalledSourcePath(sourcePath) {
2709
2751
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
2710
2752
  }
2711
2753
  function resolveCanonVersion(explicit) {
2712
- return explicit ?? "3.1.0" ?? "dev";
2754
+ return explicit ?? "3.1.2" ?? "dev";
2713
2755
  }
2714
2756
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
2715
2757
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
2716
2758
  if (!ownToplevel) return upstreamCommit;
2717
- const parentDir = path9.dirname(repoRoot);
2759
+ const parentDir = path10.dirname(repoRoot);
2718
2760
  const parentToplevel = captureGitOutput(parentDir, ["rev-parse", "--show-toplevel"], runGitAt);
2719
2761
  if (!parentToplevel) return upstreamCommit;
2720
- if (path9.resolve(parentToplevel) === path9.resolve(ownToplevel)) {
2762
+ if (path10.resolve(parentToplevel) === path10.resolve(ownToplevel)) {
2721
2763
  return upstreamCommit;
2722
2764
  }
2723
- return captureGitOutput(path9.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
2765
+ return captureGitOutput(path10.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
2724
2766
  }
2725
2767
  function defaultRunCommand(command2, args2) {
2726
2768
  const result = spawnSync6(command2, args2, {
@@ -2754,7 +2796,7 @@ function captureCanonSnapshot(repoRoot = REPO_ROOT, options = {}) {
2754
2796
  const isInstalled = isInstalledSourcePath(canonSourcePath);
2755
2797
  const superprojectWorkingTree = captureGitOutput(repoRoot, ["rev-parse", "--show-superproject-working-tree"], runGitAt);
2756
2798
  const drivingCommit = captureGitOutput(repoRoot, ["rev-parse", "HEAD"], runGitAt) || "<unavailable>";
2757
- const hostCommit = superprojectWorkingTree ? captureGitOutput(path9.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
2799
+ const hostCommit = superprojectWorkingTree ? captureGitOutput(path10.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
2758
2800
  let upstreamCommit;
2759
2801
  let orchestratorCommit;
2760
2802
  if (isInstalled) {
@@ -2801,12 +2843,50 @@ function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
2801
2843
  }
2802
2844
 
2803
2845
  // src/orchestrator/review-archive.ts
2804
- import fs11 from "fs";
2846
+ import fs12 from "fs";
2847
+ import path13 from "path";
2848
+
2849
+ // src/task/templates.ts
2850
+ import fs10 from "fs";
2805
2851
  import path11 from "path";
2852
+ function tasksRoot() {
2853
+ return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
2854
+ }
2855
+ function templatesRoot() {
2856
+ return path11.join(process.cwd(), ".canon", "templates");
2857
+ }
2858
+ function taskTemplateOverrideRoot() {
2859
+ return path11.join(tasksRoot(), "_templates");
2860
+ }
2861
+ function resolveTaskTemplateSource(basename2) {
2862
+ const override = path11.join(taskTemplateOverrideRoot(), basename2);
2863
+ if (fs10.existsSync(override)) return override;
2864
+ const managed = path11.join(templatesRoot(), basename2);
2865
+ return fs10.existsSync(managed) ? managed : null;
2866
+ }
2867
+ function renderTaskTemplate(source, taskId, title) {
2868
+ return fs10.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
2869
+ }
2870
+ function scaffoldTaskArtifact(taskDir, basename2, taskId, title) {
2871
+ const source = resolveTaskTemplateSource(basename2);
2872
+ if (!source) return null;
2873
+ fs10.writeFileSync(path11.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
2874
+ return source;
2875
+ }
2876
+ function isPristineTaskArtifact(content, basename2, taskId, title) {
2877
+ if (content.includes("[TASK-ID]")) return true;
2878
+ const source = resolveTaskTemplateSource(basename2);
2879
+ if (!source) return false;
2880
+ try {
2881
+ return content === renderTaskTemplate(source, taskId, title);
2882
+ } catch {
2883
+ return false;
2884
+ }
2885
+ }
2806
2886
 
2807
2887
  // src/orchestrator/validation.ts
2808
- import fs10 from "fs";
2809
- import path10 from "path";
2888
+ import fs11 from "fs";
2889
+ import path12 from "path";
2810
2890
  function computeLatestValidationResults(handoffContent) {
2811
2891
  const latest = /* @__PURE__ */ new Map();
2812
2892
  const baseline = parseTable(handoffContent, "Validation Outcomes");
@@ -2937,7 +3017,7 @@ function isTemplateUnfilled(content) {
2937
3017
  function isDoneMdTemplate(donePath) {
2938
3018
  let content;
2939
3019
  try {
2940
- content = fs10.readFileSync(donePath, "utf8");
3020
+ content = fs11.readFileSync(donePath, "utf8");
2941
3021
  } catch {
2942
3022
  return true;
2943
3023
  }
@@ -2967,16 +3047,16 @@ var PHASE_GATE_CONFIG = {
2967
3047
  human_review: {}
2968
3048
  };
2969
3049
  function resolveTaskDirForValidation(taskId, taskDirOverride) {
2970
- return taskDirOverride ? path10.join(taskDirOverride, taskId) : taskDirFor(taskId);
3050
+ return taskDirOverride ? path12.join(taskDirOverride, taskId) : taskDirFor(taskId);
2971
3051
  }
2972
3052
  function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
2973
3053
  const config2 = PHASE_GATE_CONFIG[phase];
2974
3054
  const taskDir = resolveTaskDirForValidation(taskId, taskDirOverride);
2975
3055
  if (config2.artifactName) {
2976
- const artifactPath = path10.join(taskDir, config2.artifactName);
3056
+ const artifactPath = path12.join(taskDir, config2.artifactName);
2977
3057
  let content;
2978
3058
  try {
2979
- content = fs10.readFileSync(artifactPath, "utf8");
3059
+ content = fs11.readFileSync(artifactPath, "utf8");
2980
3060
  } catch {
2981
3061
  return { ok: false, reason: `${config2.artifactName} is missing for phase '${phase}'` };
2982
3062
  }
@@ -2988,7 +3068,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
2988
3068
  if (phase === "spec_review" || phase === "plan") {
2989
3069
  let statusRaw;
2990
3070
  try {
2991
- statusRaw = fs10.readFileSync(path10.join(taskDir, "status.json"), "utf8");
3071
+ statusRaw = fs11.readFileSync(path12.join(taskDir, "status.json"), "utf8");
2992
3072
  } catch {
2993
3073
  return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
2994
3074
  }
@@ -3023,19 +3103,19 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3023
3103
  }
3024
3104
  }
3025
3105
  if (phase === "human_review") {
3026
- const handoffPath = path10.join(taskDir, "handoff.md");
3106
+ const handoffPath = path12.join(taskDir, "handoff.md");
3027
3107
  let handoffContent;
3028
3108
  try {
3029
- handoffContent = fs10.readFileSync(handoffPath, "utf8");
3109
+ handoffContent = fs11.readFileSync(handoffPath, "utf8");
3030
3110
  } catch {
3031
3111
  return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
3032
3112
  }
3033
3113
  const pending = countHumanPendingChecks(handoffContent);
3034
3114
  if (pending.length === 0) return { ok: true };
3035
- const donePath = path10.join(taskDir, "done.md");
3115
+ const donePath = path12.join(taskDir, "done.md");
3036
3116
  let doneContent = "";
3037
3117
  try {
3038
- doneContent = fs10.readFileSync(donePath, "utf8");
3118
+ doneContent = fs11.readFileSync(donePath, "utf8");
3039
3119
  } catch {
3040
3120
  }
3041
3121
  if (hasHumanPendingWaiver(doneContent)) return { ok: true };
@@ -3050,10 +3130,10 @@ ${list}
3050
3130
  return { ok: true };
3051
3131
  }
3052
3132
  function parseHandoffChangesRows(taskId) {
3053
- const handoffPath = path10.join(taskDirFor(taskId), "handoff.md");
3133
+ const handoffPath = path12.join(taskDirFor(taskId), "handoff.md");
3054
3134
  let content;
3055
3135
  try {
3056
- content = fs10.readFileSync(handoffPath, "utf8");
3136
+ content = fs11.readFileSync(handoffPath, "utf8");
3057
3137
  } catch {
3058
3138
  return { files: [], malformed: [] };
3059
3139
  }
@@ -3357,23 +3437,35 @@ var REVIEW_ARCHIVE_PREFIX = "review-prior-";
3357
3437
  var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
3358
3438
  function newestReviewArchiveNumber(taskDir) {
3359
3439
  let newest = 0;
3360
- for (const name of fs11.readdirSync(taskDir)) {
3440
+ for (const name of fs12.readdirSync(taskDir)) {
3361
3441
  const match = REVIEW_ARCHIVE_RE.exec(name);
3362
3442
  if (match) newest = Math.max(newest, Number(match[1]));
3363
3443
  }
3364
3444
  return newest;
3365
3445
  }
3366
3446
  function archivePriorReview(taskDir, options = {}) {
3367
- const reviewPath = path11.join(taskDir, "review.md");
3368
- if (!fs11.existsSync(reviewPath)) return null;
3447
+ const reviewPath = path13.join(taskDir, "review.md");
3448
+ if (!fs12.existsSync(reviewPath)) return null;
3369
3449
  if (options.skipUnfilledTemplate) {
3370
- const content = fs11.readFileSync(reviewPath, "utf8");
3450
+ const content = fs12.readFileSync(reviewPath, "utf8");
3371
3451
  if (isTemplateUnfilled(content)) return null;
3452
+ if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
3453
+ return null;
3454
+ }
3372
3455
  }
3373
3456
  const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
3374
- fs11.renameSync(reviewPath, path11.join(taskDir, archiveName));
3457
+ fs12.renameSync(reviewPath, path13.join(taskDir, archiveName));
3375
3458
  return archiveName;
3376
3459
  }
3460
+ function rescaffoldReview(taskDir, identity) {
3461
+ try {
3462
+ if (fs12.existsSync(path13.join(taskDir, "review.md"))) return { outcome: "exists" };
3463
+ const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
3464
+ return source ? { outcome: "written", source } : { outcome: "no-template" };
3465
+ } catch (error) {
3466
+ return { outcome: "error", message: error instanceof Error ? error.message : String(error) };
3467
+ }
3468
+ }
3377
3469
 
3378
3470
  // src/task/index.ts
3379
3471
  var VALID_PHASES = new Set(PHASE_ORDER);
@@ -3421,35 +3513,26 @@ function validateTaskId(id) {
3421
3513
  throw new Error(`Error: invalid task ID '${id}'. Must not contain '..'.`);
3422
3514
  }
3423
3515
  }
3424
- function tasksRoot() {
3425
- return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
3426
- }
3427
3516
  function taskDirFromRoot(taskId) {
3428
- return path12.join(tasksRoot(), taskId);
3517
+ return path14.join(tasksRoot(), taskId);
3429
3518
  }
3430
3519
  function taskDirForCwd(_cwd, taskId) {
3431
3520
  const root = tasksRoot();
3432
- if (path12.isAbsolute(root)) {
3433
- return path12.join(root, taskId);
3521
+ if (path14.isAbsolute(root)) {
3522
+ return path14.join(root, taskId);
3434
3523
  }
3435
- return path12.join(resolveTaskCwd(taskId), root, taskId);
3524
+ return path14.join(resolveTaskCwd(taskId), root, taskId);
3436
3525
  }
3437
3526
  function taskStatusFileForCwd(cwd, taskId) {
3438
- return path12.join(taskDirForCwd(cwd, taskId), "status.json");
3527
+ return path14.join(taskDirForCwd(cwd, taskId), "status.json");
3439
3528
  }
3440
3529
  function taskRootForGate(cwd) {
3441
3530
  const root = tasksRoot();
3442
- return path12.isAbsolute(root) ? root : path12.join(cwd, root);
3443
- }
3444
- function templatesRoot() {
3445
- return path12.join(process.cwd(), ".canon", "templates");
3446
- }
3447
- function taskTemplateOverrideRoot() {
3448
- return path12.join(tasksRoot(), "_templates");
3531
+ return path14.isAbsolute(root) ? root : path14.join(cwd, root);
3449
3532
  }
3450
3533
  function readJsonFile(filePath) {
3451
3534
  try {
3452
- return JSON.parse(fs12.readFileSync(filePath, "utf8"));
3535
+ return JSON.parse(fs13.readFileSync(filePath, "utf8"));
3453
3536
  } catch (error) {
3454
3537
  const message = error instanceof Error ? error.message : String(error);
3455
3538
  throw new Error(`Error: failed to read ${filePath}: ${message}`);
@@ -3457,9 +3540,9 @@ function readJsonFile(filePath) {
3457
3540
  }
3458
3541
  function writeJsonAtomic(filePath, data) {
3459
3542
  const tmpFile = `${filePath}.tmp`;
3460
- fs12.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3543
+ fs13.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3461
3544
  `, "utf8");
3462
- fs12.renameSync(tmpFile, filePath);
3545
+ fs13.renameSync(tmpFile, filePath);
3463
3546
  }
3464
3547
  function writeStatusAtomic(filePath, status) {
3465
3548
  status.status = deriveTopLevelStatus(status);
@@ -3498,21 +3581,17 @@ function currentBranchOrEmpty() {
3498
3581
  if (result.error || result.status !== 0) return "";
3499
3582
  return (result.stdout ?? "").trim();
3500
3583
  }
3501
- function copyTemplateFile(source, destination, taskId, title) {
3502
- const content = fs12.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
3503
- fs12.writeFileSync(destination, content, "utf8");
3504
- }
3505
3584
  function listTemplateFiles() {
3506
3585
  const root = templatesRoot();
3507
- if (!fs12.existsSync(root)) {
3586
+ if (!fs13.existsSync(root)) {
3508
3587
  throw new Error(`Error: templates directory not found at ${root}`);
3509
3588
  }
3510
- return fs12.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3589
+ return fs13.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3511
3590
  }
3512
3591
  function printCreatedTask(taskDir, baseBranch) {
3513
3592
  console.log(`Created task: ${taskDir}`);
3514
3593
  console.log("Files:");
3515
- for (const file of fs12.readdirSync(taskDir).sort()) {
3594
+ for (const file of fs13.readdirSync(taskDir).sort()) {
3516
3595
  console.log(file);
3517
3596
  }
3518
3597
  console.log("");
@@ -3550,20 +3629,18 @@ function taskNew(args2) {
3550
3629
  throw new Error("Error: title must be single-line (no embedded newlines).");
3551
3630
  }
3552
3631
  const taskDir = taskDirFromRoot(id);
3553
- if (fs12.existsSync(taskDir)) {
3632
+ if (fs13.existsSync(taskDir)) {
3554
3633
  throw new Error(`Error: Task directory ${taskDir} already exists.`);
3555
3634
  }
3556
3635
  if (!baseBranch) {
3557
3636
  baseBranch = currentBranchOrEmpty() || "main";
3558
3637
  }
3559
- fs12.mkdirSync(taskDir, { recursive: true });
3560
- const overrideRoot = taskTemplateOverrideRoot();
3638
+ fs13.mkdirSync(taskDir, { recursive: true });
3561
3639
  for (const basename2 of listTemplateFiles()) {
3562
- const override = path12.join(overrideRoot, basename2);
3563
- const source = fs12.existsSync(override) ? override : path12.join(templatesRoot(), basename2);
3564
- copyTemplateFile(source, path12.join(taskDir, basename2), id, title);
3640
+ const source = resolveTaskTemplateSource(basename2) ?? path14.join(templatesRoot(), basename2);
3641
+ fs13.writeFileSync(path14.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
3565
3642
  }
3566
- const statusPath = path12.join(taskDir, "status.json");
3643
+ const statusPath = path14.join(taskDir, "status.json");
3567
3644
  const status = readJsonFile(statusPath);
3568
3645
  status.id = id;
3569
3646
  status.title = title;
@@ -3584,31 +3661,31 @@ function derivePhase(status) {
3584
3661
  }
3585
3662
  function taskList() {
3586
3663
  const root = tasksRoot();
3587
- if (!fs12.existsSync(root)) {
3664
+ if (!fs13.existsSync(root)) {
3588
3665
  console.log("No tasks found.");
3589
3666
  return;
3590
3667
  }
3591
3668
  const rows = [];
3592
3669
  let invalidCount = 0;
3593
- for (const entry of fs12.readdirSync(root).sort()) {
3670
+ for (const entry of fs13.readdirSync(root).sort()) {
3594
3671
  if (entry === "_archive" || entry === "_templates") continue;
3595
3672
  if (isOrphanedWorktreeState(entry)) {
3596
3673
  invalidCount += 1;
3597
3674
  let title = "(untitled)";
3598
3675
  try {
3599
- const frozen = readJsonFile(path12.join(taskDirForRepoRoot(entry), "status.json"));
3676
+ const frozen = readJsonFile(path14.join(taskDirForRepoRoot(entry), "status.json"));
3600
3677
  title = frozen.title ?? title;
3601
3678
  } catch {
3602
3679
  }
3603
3680
  rows.push({
3604
3681
  id: entry,
3605
3682
  title,
3606
- phase: `INVALID: worktree missing \u2014 restore ${path12.join(effectiveWorktreesRoot(), entry)} or archive the task`
3683
+ phase: `INVALID: worktree missing \u2014 restore ${path14.join(effectiveWorktreesRoot(), entry)} or archive the task`
3607
3684
  });
3608
3685
  continue;
3609
3686
  }
3610
- const statusPath = path12.join(taskDirForCwd(process.cwd(), entry), "status.json");
3611
- if (!fs12.existsSync(statusPath)) continue;
3687
+ const statusPath = path14.join(taskDirForCwd(process.cwd(), entry), "status.json");
3688
+ if (!fs13.existsSync(statusPath)) continue;
3612
3689
  try {
3613
3690
  const status = readJsonFile(statusPath);
3614
3691
  const phase = derivePhase(status);
@@ -3645,7 +3722,7 @@ function taskStatus(id) {
3645
3722
  validateTaskId(id);
3646
3723
  const cwd = resolveTaskCwd(id);
3647
3724
  const statusPath = taskStatusFileForCwd(cwd, id);
3648
- if (!fs12.existsSync(statusPath)) {
3725
+ if (!fs13.existsSync(statusPath)) {
3649
3726
  throw new Error(`Error: No status.json found for task ${id}`);
3650
3727
  }
3651
3728
  const status = readJsonFile(statusPath);
@@ -3718,7 +3795,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
3718
3795
  assertValidVerdict(phaseArg, verdictArg);
3719
3796
  const taskCwd = resolveTaskCwd(id);
3720
3797
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3721
- if (!fs12.existsSync(statusPath)) {
3798
+ if (!fs13.existsSync(statusPath)) {
3722
3799
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3723
3800
  }
3724
3801
  const status = readJsonFile(statusPath);
@@ -3760,7 +3837,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
3760
3837
  writeQualityLogForTask(
3761
3838
  id,
3762
3839
  taskCwd,
3763
- path12.join(taskDirForCwd(taskCwd, id), "done.md"),
3840
+ path14.join(taskDirForCwd(taskCwd, id), "done.md"),
3764
3841
  status
3765
3842
  );
3766
3843
  }
@@ -3784,7 +3861,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3784
3861
  for (const id of ids) {
3785
3862
  const taskCwd = resolveTaskCwd(id);
3786
3863
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3787
- if (!fs12.existsSync(statusPath)) {
3864
+ if (!fs13.existsSync(statusPath)) {
3788
3865
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3789
3866
  }
3790
3867
  const status = readJsonFile(statusPath);
@@ -3886,7 +3963,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3886
3963
  const originalSnapshots2 = /* @__PURE__ */ new Map();
3887
3964
  for (const ctx of ctxByTask.values()) {
3888
3965
  try {
3889
- originalSnapshots2.set(ctx.statusPath, fs12.readFileSync(ctx.statusPath, "utf8"));
3966
+ originalSnapshots2.set(ctx.statusPath, fs13.readFileSync(ctx.statusPath, "utf8"));
3890
3967
  } catch (error) {
3891
3968
  const message = error instanceof Error ? error.message : String(error);
3892
3969
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -3923,8 +4000,8 @@ function taskAccept(ids, phaseArg, options = {}) {
3923
4000
  if (original === void 0) continue;
3924
4001
  try {
3925
4002
  const tmpFile = `${filePath}.rollback.tmp`;
3926
- fs12.writeFileSync(tmpFile, original, "utf8");
3927
- fs12.renameSync(tmpFile, filePath);
4003
+ fs13.writeFileSync(tmpFile, original, "utf8");
4004
+ fs13.renameSync(tmpFile, filePath);
3928
4005
  } catch (rollbackErr) {
3929
4006
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
3930
4007
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -3942,18 +4019,18 @@ ${rollbackErrors.join("\n")}`
3942
4019
  throw new Error(`Error: bundled review accept failed; rolled back to pre-accept state. Original error: ${originalMessage}`);
3943
4020
  }
3944
4021
  for (const ctx of ctxByTask.values()) {
3945
- const notesPath = path12.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4022
+ const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
3946
4023
  const entry = ctx.status.phases[phaseArg];
3947
4024
  const sanctioned = entry?.verdict === "sanctioned";
3948
4025
  const bundleNote = ids.length > 1 ? ` Bundle: ${ids.join(", ")}.` : "";
3949
4026
  const noteLine = `[${today()}] Operator accepted ${phaseArg} via \`canon task accept\` \u2014 ${sanctioned ? "sanctioned (agent verdict overridden)" : "unblocked (advancing verdict preserved)"}. Reason: ${reason}.${bundleNote}`;
3950
4027
  try {
3951
- if (fs12.existsSync(notesPath)) {
3952
- fs12.appendFileSync(notesPath, `
4028
+ if (fs13.existsSync(notesPath)) {
4029
+ fs13.appendFileSync(notesPath, `
3953
4030
  ${noteLine}
3954
4031
  `, "utf8");
3955
4032
  } else {
3956
- fs12.writeFileSync(notesPath, `${noteLine}
4033
+ fs13.writeFileSync(notesPath, `${noteLine}
3957
4034
  `, "utf8");
3958
4035
  }
3959
4036
  } catch (error) {
@@ -4076,7 +4153,7 @@ ${noteLine}
4076
4153
  const originalSnapshots = /* @__PURE__ */ new Map();
4077
4154
  for (const ctx of ctxByTask.values()) {
4078
4155
  try {
4079
- originalSnapshots.set(ctx.statusPath, fs12.readFileSync(ctx.statusPath, "utf8"));
4156
+ originalSnapshots.set(ctx.statusPath, fs13.readFileSync(ctx.statusPath, "utf8"));
4080
4157
  } catch (error) {
4081
4158
  const message = error instanceof Error ? error.message : String(error);
4082
4159
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -4101,8 +4178,8 @@ ${noteLine}
4101
4178
  if (original === void 0) continue;
4102
4179
  try {
4103
4180
  const tmpFile = `${filePath}.rollback.tmp`;
4104
- fs12.writeFileSync(tmpFile, original, "utf8");
4105
- fs12.renameSync(tmpFile, filePath);
4181
+ fs13.writeFileSync(tmpFile, original, "utf8");
4182
+ fs13.renameSync(tmpFile, filePath);
4106
4183
  } catch (rollbackErr) {
4107
4184
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
4108
4185
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -4121,15 +4198,15 @@ ${noteLine}
4121
4198
  throw new Error(`Error: bundled accept failed; rolled back to pre-accept state. Original error: ${originalMessage}`);
4122
4199
  }
4123
4200
  for (const ctx of ctxByTask.values()) {
4124
- const notesPath = path12.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4201
+ const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4125
4202
  const noteLine = `[${today()}] Operator accepted implement phase via \`canon task accept\` \u2014 auto-commit will be skipped.${options.force ? " (--force)" : ""}`;
4126
4203
  try {
4127
- if (fs12.existsSync(notesPath)) {
4128
- fs12.appendFileSync(notesPath, `
4204
+ if (fs13.existsSync(notesPath)) {
4205
+ fs13.appendFileSync(notesPath, `
4129
4206
  ${noteLine}
4130
4207
  `, "utf8");
4131
4208
  } else {
4132
- fs12.writeFileSync(notesPath, `${noteLine}
4209
+ fs13.writeFileSync(notesPath, `${noteLine}
4133
4210
  `, "utf8");
4134
4211
  }
4135
4212
  } catch (error) {
@@ -4148,15 +4225,15 @@ function taskResetSpecReview(id) {
4148
4225
  validateTaskId(id);
4149
4226
  const taskCwd = resolveTaskCwd(id);
4150
4227
  const taskDir = taskDirForCwd(taskCwd, id);
4151
- const statusPath = path12.join(taskDir, "status.json");
4152
- if (!fs12.existsSync(statusPath)) {
4228
+ const statusPath = path14.join(taskDir, "status.json");
4229
+ if (!fs13.existsSync(statusPath)) {
4153
4230
  throw new Error(`Error: no status.json at ${statusPath}`);
4154
4231
  }
4155
- const reviewPath = path12.join(taskDir, "spec-review.md");
4156
- if (fs12.existsSync(reviewPath)) {
4232
+ const reviewPath = path14.join(taskDir, "spec-review.md");
4233
+ if (fs13.existsSync(reviewPath)) {
4157
4234
  let n = 1;
4158
- while (fs12.existsSync(path12.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4159
- fs12.renameSync(reviewPath, path12.join(taskDir, `spec-review-prior-${n}.md`));
4235
+ while (fs13.existsSync(path14.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4236
+ fs13.renameSync(reviewPath, path14.join(taskDir, `spec-review-prior-${n}.md`));
4160
4237
  console.log(`Archived prior spec-review.md \u2192 spec-review-prior-${n}.md`);
4161
4238
  }
4162
4239
  const status = readJsonFile(statusPath);
@@ -4179,8 +4256,8 @@ function taskResetCodeReview(id) {
4179
4256
  validateTaskId(id);
4180
4257
  const taskCwd = resolveTaskCwd(id);
4181
4258
  const taskDir = taskDirForCwd(taskCwd, id);
4182
- const statusPath = path12.join(taskDir, "status.json");
4183
- if (!fs12.existsSync(statusPath)) {
4259
+ const statusPath = path14.join(taskDir, "status.json");
4260
+ if (!fs13.existsSync(statusPath)) {
4184
4261
  throw new Error(`Error: no status.json at ${statusPath}`);
4185
4262
  }
4186
4263
  const status = readJsonFile(statusPath);
@@ -4193,6 +4270,18 @@ function taskResetCodeReview(id) {
4193
4270
  if (archivedReview) {
4194
4271
  console.log(`Archived prior review.md \u2192 ${archivedReview}`);
4195
4272
  }
4273
+ const rescaffold = rescaffoldReview(taskDir, { taskId: id, title: status.title ?? "" });
4274
+ if (rescaffold.outcome === "written") {
4275
+ console.log(`Re-scaffolded review.md from ${path14.relative(process.cwd(), rescaffold.source)}`);
4276
+ } else if (rescaffold.outcome === "no-template") {
4277
+ console.warn(
4278
+ "Warning: review.md is missing and no template was found to re-scaffold it (looked for a review.md override under tasks/_templates/ and .canon/templates/review.md); the next code review will write it from scratch."
4279
+ );
4280
+ } else if (rescaffold.outcome === "error") {
4281
+ console.warn(
4282
+ `Warning: review.md is missing and could not be re-scaffolded: ${rescaffold.message}; the next code review will write it from scratch.`
4283
+ );
4284
+ }
4196
4285
  const implement = ensurePhaseEntry(status, "implement");
4197
4286
  const codeReview = ensurePhaseEntry(status, "code_review");
4198
4287
  implement.status = "done";
@@ -4226,16 +4315,16 @@ function parsePorcelainPath(line) {
4226
4315
  }
4227
4316
  function isPipelineOwnedAcceptPath(filePath, taskId, gitCwd) {
4228
4317
  const repoRootForPaths = resolveRepoRootForAccept(gitCwd);
4229
- const dirtyAbsolute = path12.isAbsolute(filePath) ? filePath : path12.resolve(repoRootForPaths, filePath);
4318
+ const dirtyAbsolute = path14.isAbsolute(filePath) ? filePath : path14.resolve(repoRootForPaths, filePath);
4230
4319
  const canonicalDirty = safeRealpath(dirtyAbsolute);
4231
4320
  const root = tasksRoot();
4232
- const rootAbsolute = path12.isAbsolute(root) ? root : path12.resolve(repoRootForPaths, root);
4321
+ const rootAbsolute = path14.isAbsolute(root) ? root : path14.resolve(repoRootForPaths, root);
4233
4322
  const canonicalRoot = safeRealpath(rootAbsolute);
4234
- const taskCanonical = path12.join(canonicalRoot, taskId);
4323
+ const taskCanonical = path14.join(canonicalRoot, taskId);
4235
4324
  if (canonicalDirty === taskCanonical) return true;
4236
- if (canonicalDirty.startsWith(`${taskCanonical}${path12.sep}`)) return true;
4325
+ if (canonicalDirty.startsWith(`${taskCanonical}${path14.sep}`)) return true;
4237
4326
  for (const telemetry of PIPELINE_TELEMETRY_FILES) {
4238
- const telemetryAbsolute = path12.resolve(repoRootForPaths, telemetry);
4327
+ const telemetryAbsolute = path14.resolve(repoRootForPaths, telemetry);
4239
4328
  if (safeRealpath(telemetryAbsolute) === canonicalDirty) return true;
4240
4329
  }
4241
4330
  return false;
@@ -4250,16 +4339,16 @@ function resolveMainCheckoutRoot() {
4250
4339
  if (out.error || out.status !== 0) return process.cwd();
4251
4340
  const gitCommonDir = (out.stdout ?? "").trim();
4252
4341
  if (!gitCommonDir) return process.cwd();
4253
- return path12.dirname(gitCommonDir);
4342
+ return path14.dirname(gitCommonDir);
4254
4343
  }
4255
4344
  function safeRealpath(target) {
4256
4345
  try {
4257
- return fs12.realpathSync(target);
4346
+ return fs13.realpathSync(target);
4258
4347
  } catch {
4259
- const parent = path12.dirname(target);
4348
+ const parent = path14.dirname(target);
4260
4349
  if (parent === target) return target;
4261
4350
  try {
4262
- return path12.join(fs12.realpathSync(parent), path12.basename(target));
4351
+ return path14.join(fs13.realpathSync(parent), path14.basename(target));
4263
4352
  } catch {
4264
4353
  return target;
4265
4354
  }
@@ -4367,12 +4456,12 @@ function taskPostMergeSync(branchArg) {
4367
4456
  }
4368
4457
  function nudgeShippableTasks() {
4369
4458
  const root = tasksRoot();
4370
- if (!fs12.existsSync(root)) return;
4459
+ if (!fs13.existsSync(root)) return;
4371
4460
  const shippable = [];
4372
- for (const entry of fs12.readdirSync(root).sort()) {
4461
+ for (const entry of fs13.readdirSync(root).sort()) {
4373
4462
  if (entry === "_archive" || entry.startsWith("_")) continue;
4374
- const statusPath = path12.join(root, entry, "status.json");
4375
- if (!fs12.existsSync(statusPath)) continue;
4463
+ const statusPath = path14.join(root, entry, "status.json");
4464
+ if (!fs13.existsSync(statusPath)) continue;
4376
4465
  let status;
4377
4466
  try {
4378
4467
  status = readJsonFile(statusPath);
@@ -4447,7 +4536,7 @@ function taskSet(args2) {
4447
4536
  validateTaskId(id);
4448
4537
  const taskCwd = resolveTaskCwd(id);
4449
4538
  const statusPath = taskStatusFileForCwd(taskCwd, id);
4450
- if (!fs12.existsSync(statusPath)) {
4539
+ if (!fs13.existsSync(statusPath)) {
4451
4540
  throw new Error(`Error: No status.json found for task ${id}`);
4452
4541
  }
4453
4542
  const status = readJsonFile(statusPath);
@@ -4788,7 +4877,7 @@ function currentPinFromManifest(manifest) {
4788
4877
  return "unknown";
4789
4878
  }
4790
4879
  function bakedVersion() {
4791
- return "3.1.0";
4880
+ return "3.1.2";
4792
4881
  }
4793
4882
  function ownPackageName(pkgDir) {
4794
4883
  try {
@@ -5170,6 +5259,14 @@ function printUpgradeRefusals(refusals, prefix) {
5170
5259
  }
5171
5260
  }
5172
5261
  function runUpgrade(cwd, pkgDir, options = {}) {
5262
+ assertNoSymlinkDestinations(cwd, [
5263
+ ...CANON_OWNED,
5264
+ ...DELIMITED,
5265
+ ...HEADER_ONLY_SYNC,
5266
+ ".canon/version",
5267
+ ".gitignore",
5268
+ "scripts/docs-refs-config.mjs"
5269
+ ]);
5173
5270
  const upgraded = [];
5174
5271
  const unchanged = [];
5175
5272
  const skipped = [];
@@ -5254,7 +5351,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5254
5351
  cutoverWarnings.push(docsRefsCheckRel);
5255
5352
  }
5256
5353
  const versionPath = join5(cwd, ".canon", "version");
5257
- const newVersion = "3.1.0";
5354
+ const newVersion = "3.1.2";
5258
5355
  const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
5259
5356
  if (currentVersion !== newVersion) {
5260
5357
  pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
@@ -5332,6 +5429,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5332
5429
  const reportedWrites = options.force ? pending : clean;
5333
5430
  const staleOverrides = getStaleOverrides(cwd, reportedWrites);
5334
5431
  const toWrite = options.force ? pending : clean;
5432
+ assertNoSymlinkDestinations(cwd, toWrite.map((op) => op.rel));
5335
5433
  for (const op of toWrite) {
5336
5434
  mkdirSync3(dirname5(op.projectPath), { recursive: true });
5337
5435
  writeFileSync3(op.projectPath, op.content);
@@ -5561,7 +5659,7 @@ Global:
5561
5659
  `);
5562
5660
  }
5563
5661
  function printVersion() {
5564
- console.log("3.1.0");
5662
+ console.log("3.1.2");
5565
5663
  }
5566
5664
  switch (command) {
5567
5665
  case "doctor":