canon-ai 3.7.0 → 3.9.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/dist/cli/index.js CHANGED
@@ -1,28 +1,93 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli/commands/doctor.ts
4
- import { execSync } from "child_process";
5
- import { existsSync, readdirSync, readFileSync, realpathSync } from "fs";
6
- import { homedir } from "os";
7
- import { dirname, join, relative, sep as pathSep } from "path";
3
+ // src/orchestrator/docs-freshness.ts
4
+ import fs7 from "fs";
5
+ import path6 from "path";
8
6
 
9
- // src/orchestrator/heartbeat.ts
7
+ // src/orchestrator/cli.ts
10
8
  import fs from "fs";
9
+ var exitReason = null;
10
+ var originalProcessExit = process.exit.bind(process);
11
+ function setExitReason(reason) {
12
+ exitReason = reason;
13
+ }
14
+ function die(message) {
15
+ setExitReason(message);
16
+ console.error(`\u274C ${message}`);
17
+ process.exit(1);
18
+ }
19
+ function warn(message) {
20
+ console.error(`\u26A0\uFE0F ${message}`);
21
+ }
22
+
23
+ // src/orchestrator/worktree.ts
24
+ import fs6 from "fs";
25
+ import path5 from "path";
26
+
27
+ // src/orchestrator/env.ts
28
+ import { spawnSync } from "child_process";
29
+ import fs2 from "fs";
11
30
  import path from "path";
31
+ import { fileURLToPath } from "url";
32
+ var __filename = fileURLToPath(import.meta.url);
33
+ var __dirname = path.dirname(__filename);
34
+ function resolveRepoRoot() {
35
+ try {
36
+ const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
37
+ if (result.error || result.status !== 0) {
38
+ throw result.error ?? new Error(result.stderr || "git rev-parse --git-common-dir failed");
39
+ }
40
+ const gitCommonDir = result.stdout.trim();
41
+ if (!gitCommonDir) throw new Error("git rev-parse --git-common-dir returned no path");
42
+ const resolvedGitCommonDir = path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(process.cwd(), gitCommonDir);
43
+ return path.dirname(resolvedGitCommonDir);
44
+ } catch {
45
+ return path.resolve(__dirname, "../..");
46
+ }
47
+ }
48
+ var REPO_ROOT = resolveRepoRoot();
49
+ var TASKS_DIR = path.join(REPO_ROOT, "tasks");
50
+ var WORKTREES_ROOT = process.env.CANON_WORKTREES_ROOT ? path.resolve(REPO_ROOT, process.env.CANON_WORKTREES_ROOT) : path.resolve(REPO_ROOT, ".canon/worktrees");
51
+ var STALL_TIMEOUT_MS = Number(process.env.PIPELINE_STALL_TIMEOUT_MS) || 10 * 60 * 1e3;
52
+ function resolveProjectName() {
53
+ if (process.env.CANON_PROJECT_NAME) return process.env.CANON_PROJECT_NAME;
54
+ try {
55
+ const pkgPath = path.join(REPO_ROOT, "package.json");
56
+ if (fs2.existsSync(pkgPath)) {
57
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
58
+ if (pkg.name) return pkg.name;
59
+ }
60
+ } catch {
61
+ }
62
+ return "your project";
63
+ }
64
+ var config = {
65
+ projectName: resolveProjectName(),
66
+ maxContextBytes: Number.parseInt(process.env.MAX_CONTEXT_BYTES ?? String(64 * 1024), 10)
67
+ };
68
+
69
+ // src/orchestrator/git.ts
70
+ import { spawnSync as spawnSync3 } from "child_process";
71
+ import fs5 from "fs";
72
+ import path4 from "path";
73
+
74
+ // src/orchestrator/heartbeat.ts
75
+ import fs3 from "fs";
76
+ import path2 from "path";
12
77
  var HEARTBEAT_FILENAME = ".heartbeat.json";
13
78
  var HEARTBEAT_INTERVAL_MS = 3e4;
14
79
  var HEARTBEAT_STALE_AFTER_MS = HEARTBEAT_INTERVAL_MS * 2;
15
80
  function removeHeartbeat(taskDir) {
16
81
  try {
17
- fs.unlinkSync(path.join(taskDir, ".heartbeat.json"));
82
+ fs3.unlinkSync(path2.join(taskDir, ".heartbeat.json"));
18
83
  } catch {
19
84
  }
20
85
  }
21
86
  function readHeartbeatStatus(taskDir) {
22
- const file = path.join(taskDir, HEARTBEAT_FILENAME);
87
+ const file = path2.join(taskDir, HEARTBEAT_FILENAME);
23
88
  let raw;
24
89
  try {
25
- raw = fs.readFileSync(file, "utf8");
90
+ raw = fs3.readFileSync(file, "utf8");
26
91
  } catch (error) {
27
92
  const err = error;
28
93
  if (err.code === "ENOENT") return { kind: "missing" };
@@ -45,72 +110,536 @@ function isHeartbeatStale(record, now = Date.now()) {
45
110
  return now - record.last_update_ms > HEARTBEAT_STALE_AFTER_MS;
46
111
  }
47
112
 
48
- // src/orchestrator/quality-log.ts
49
- import fs3 from "fs";
50
- import path2 from "path";
113
+ // src/orchestrator/state.ts
114
+ import fs4 from "fs";
115
+ import { spawnSync as spawnSync2 } from "child_process";
116
+ import path3 from "path";
51
117
 
52
- // src/orchestrator/cli.ts
53
- import fs2 from "fs";
54
- var exitReason = null;
55
- var originalProcessExit = process.exit.bind(process);
56
- function setExitReason(reason) {
57
- exitReason = reason;
118
+ // src/orchestrator/types.ts
119
+ var PHASE_ORDER = ["spec", "spec_review", "plan", "implement", "code_review", "qa", "human_review"];
120
+
121
+ // src/orchestrator/state.ts
122
+ function effectiveWorktreesRoot() {
123
+ return process.env.CANON_WORKTREES_ROOT ? path3.resolve(REPO_ROOT, process.env.CANON_WORKTREES_ROOT) : WORKTREES_ROOT;
58
124
  }
59
- function die(message) {
60
- setExitReason(message);
61
- console.error(`\u274C ${message}`);
62
- process.exit(1);
125
+ function isPathInside(child, parent) {
126
+ if (child === parent) return true;
127
+ const rel = path3.relative(parent, child);
128
+ return rel !== "" && !rel.startsWith("..") && !path3.isAbsolute(rel);
63
129
  }
64
- function warn(message) {
65
- console.error(`\u26A0\uFE0F ${message}`);
130
+ function classifyInvocationRoot(params) {
131
+ const { activeToplevel, mainRoot, worktreesRoot } = params;
132
+ if (!activeToplevel) return { kind: "unknown" };
133
+ if (activeToplevel === mainRoot) return { kind: "main" };
134
+ if (isPathInside(activeToplevel, worktreesRoot)) {
135
+ return { kind: "canon-worktree", activeRoot: activeToplevel };
136
+ }
137
+ return { kind: "foreign-worktree", activeRoot: activeToplevel, mainRoot, worktreesRoot };
138
+ }
139
+ function canonicalizePath(p) {
140
+ try {
141
+ return fs4.realpathSync(p);
142
+ } catch {
143
+ return p;
144
+ }
145
+ }
146
+ function readActiveToplevel() {
147
+ const result = spawnSync2("git", ["rev-parse", "--show-toplevel"], {
148
+ encoding: "utf8",
149
+ stdio: ["ignore", "pipe", "ignore"]
150
+ });
151
+ if (result.error || result.status !== 0) return null;
152
+ const out = (result.stdout ?? "").trim();
153
+ return out === "" ? null : out;
66
154
  }
155
+ function assertManagedInvocationRoot() {
156
+ if (process.env.CANON_TASKS_DIR_OVERRIDE) return;
157
+ const activeToplevel = readActiveToplevel();
158
+ const classification = classifyInvocationRoot({
159
+ activeToplevel: activeToplevel ? canonicalizePath(activeToplevel) : null,
160
+ mainRoot: canonicalizePath(REPO_ROOT),
161
+ worktreesRoot: canonicalizePath(effectiveWorktreesRoot())
162
+ });
163
+ if (classification.kind !== "foreign-worktree") return;
164
+ die(
165
+ `Canon was invoked from a linked git worktree it does not manage:
166
+ ${classification.activeRoot}
67
167
 
68
- // src/orchestrator/markdown-table.ts
69
- function splitTableLine(line) {
70
- const cells = [];
71
- let cell = "";
72
- let backslashes = 0;
73
- for (const char of line) {
74
- if (char === "\\") {
75
- backslashes += 1;
76
- continue;
168
+ Canon manages task worktrees itself \u2014 it creates them under
169
+ ${classification.worktreesRoot}
170
+ and routes task state there automatically. Running task commands from a
171
+ hand-created linked worktree is unsupported: task state would be written
172
+ here but read from the main checkout, so dirty-state, base-branch, and
173
+ worktree-safety checks would all evaluate the wrong tree.
174
+
175
+ Run canon from the main checkout instead:
176
+ ${classification.mainRoot}
177
+ and let canon create the task's worktree. If you intend THIS directory to be
178
+ canon's managed worktrees root, set CANON_WORKTREES_ROOT accordingly.
179
+
180
+ This also covers a worktree canon itself created under an earlier default
181
+ worktrees-root location \u2014 to migrate it, move the directory under the
182
+ current root shown above and, from the main checkout, run
183
+ \`git worktree repair <new path>\` (the path argument is required for a moved worktree).`
184
+ );
185
+ }
186
+ function listWorktreesWithBranches() {
187
+ const result = spawnSync2("git", ["worktree", "list", "--porcelain"], {
188
+ cwd: REPO_ROOT,
189
+ encoding: "utf8",
190
+ stdio: ["ignore", "pipe", "pipe"]
191
+ });
192
+ if (result.error || result.status !== 0) {
193
+ return {
194
+ ok: false,
195
+ stderr: result.error ? result.error.message : (result.stderr ?? "").trim()
196
+ };
197
+ }
198
+ const worktrees = [];
199
+ let currentPath = null;
200
+ let currentBranch = null;
201
+ const flush = () => {
202
+ if (currentPath && currentPath !== REPO_ROOT) {
203
+ worktrees.push({ path: currentPath, branch: currentBranch });
77
204
  }
78
- if (char === "|") {
79
- if (backslashes % 2 === 1) {
80
- cell += "\\".repeat((backslashes - 1) / 2) + "|";
81
- } else {
82
- cell += "\\".repeat(backslashes / 2);
83
- cells.push(cell);
84
- cell = "";
85
- }
86
- backslashes = 0;
87
- continue;
205
+ };
206
+ for (const line of (result.stdout ?? "").split("\n")) {
207
+ if (line.startsWith("worktree ")) {
208
+ flush();
209
+ currentPath = line.slice("worktree ".length).trim();
210
+ currentBranch = null;
211
+ } else if (line.startsWith("branch refs/heads/")) {
212
+ currentBranch = line.slice("branch refs/heads/".length).trim();
88
213
  }
89
- if (backslashes > 0) {
90
- cell += "\\".repeat(backslashes);
91
- backslashes = 0;
214
+ }
215
+ flush();
216
+ return { ok: true, worktrees };
217
+ }
218
+ function scanWorktreesForSecondaryOwnership(taskId) {
219
+ const enumeration = listWorktreesWithBranches();
220
+ if (!enumeration.ok) return { outcome: "enumeration-failed" };
221
+ const matches = [];
222
+ for (const { path: worktreePath, branch: checkedOutBranch } of enumeration.worktrees) {
223
+ const candidateStatusPath = path3.join(worktreePath, "tasks", taskId, "status.json");
224
+ if (!fs4.existsSync(candidateStatusPath)) continue;
225
+ let candidate;
226
+ try {
227
+ candidate = readStatusFromPath(candidateStatusPath, taskId);
228
+ } catch (err) {
229
+ return {
230
+ outcome: "present-but-invalid",
231
+ worktreePath,
232
+ error: err instanceof Error ? err.message : String(err)
233
+ };
234
+ }
235
+ if (candidate.worktree !== true || checkedOutBranch === null) continue;
236
+ const candidateBranch = candidate.branch?.trim() ?? "";
237
+ if (candidateBranch && candidateBranch === checkedOutBranch) {
238
+ matches.push(worktreePath);
92
239
  }
93
- cell += char;
94
240
  }
95
- if (backslashes > 0) cell += "\\".repeat(backslashes);
96
- cells.push(cell);
97
- return cells;
241
+ if (matches.length === 1) return { outcome: "matched", worktreePath: matches[0] };
242
+ if (matches.length >= 2) return { outcome: "ambiguous", worktreePaths: matches };
243
+ return { outcome: "no-match" };
98
244
  }
99
- function normalizeCells(line) {
100
- const cells = splitTableLine(line.trim());
101
- const innerCells = cells.slice(
102
- (cells[0] ?? "").trim() === "" ? 1 : 0,
103
- (cells[cells.length - 1] ?? "").trim() === "" ? -1 : void 0
104
- );
105
- return innerCells.map((cell) => cell.trim());
245
+ function findExistingWorktreeForBranch(branch) {
246
+ const result = spawnSync2("git", ["worktree", "list", "--porcelain"], {
247
+ cwd: REPO_ROOT,
248
+ encoding: "utf8",
249
+ stdio: ["ignore", "pipe", "pipe"]
250
+ });
251
+ if (result.error || result.status !== 0) return null;
252
+ const lines = (result.stdout ?? "").split("\n");
253
+ let currentPath = null;
254
+ for (const line of lines) {
255
+ if (line.startsWith("worktree ")) {
256
+ currentPath = line.slice("worktree ".length).trim();
257
+ } else if (line.startsWith("branch refs/heads/") && currentPath && currentPath !== REPO_ROOT) {
258
+ const lineBranch = line.slice("branch refs/heads/".length).trim();
259
+ if (lineBranch === branch) return currentPath;
260
+ }
261
+ }
262
+ return null;
106
263
  }
107
- function isSeparatorRow(cells) {
108
- return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
264
+ function taskDirForRepoRoot(taskId) {
265
+ return path3.join(process.env.CANON_TASKS_DIR_OVERRIDE ?? TASKS_DIR, taskId);
109
266
  }
110
- function isSectionHeading(line, sectionHeading) {
111
- return line.trimEnd() === `## ${sectionHeading}`;
267
+ function taskDirFor(taskId) {
268
+ if (process.env.CANON_TASKS_DIR_OVERRIDE) {
269
+ return path3.join(process.env.CANON_TASKS_DIR_OVERRIDE, taskId);
270
+ }
271
+ return path3.join(resolveTaskCwd(taskId), "tasks", taskId);
112
272
  }
113
- function isHeadingBoundary(line) {
273
+ function isOrphanedWorktreeState(taskId) {
274
+ const worktreesRoot = effectiveWorktreesRoot();
275
+ const directWorktree = path3.join(worktreesRoot, taskId);
276
+ const directStatus = path3.join(directWorktree, "tasks", taskId, "status.json");
277
+ if (fs4.existsSync(directStatus)) return false;
278
+ const statusPath = path3.join(taskDirForRepoRoot(taskId), "status.json");
279
+ try {
280
+ const parsed = JSON.parse(fs4.readFileSync(statusPath, "utf8"));
281
+ if (parsed.worktree !== true) return false;
282
+ const branch = parsed.branch?.trim() ?? "";
283
+ if (!branch) return false;
284
+ return findExistingWorktreeForBranch(branch) === null;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
289
+ function resolveTaskCwd(taskId) {
290
+ const worktreesRoot = effectiveWorktreesRoot();
291
+ const directWorktree = path3.join(worktreesRoot, taskId);
292
+ const directStatus = path3.join(directWorktree, "tasks", taskId, "status.json");
293
+ if (fs4.existsSync(directStatus)) return directWorktree;
294
+ const statusPath = path3.join(taskDirForRepoRoot(taskId), "status.json");
295
+ try {
296
+ const parsed = JSON.parse(fs4.readFileSync(statusPath, "utf8"));
297
+ if (parsed.worktree === true) {
298
+ const branch = parsed.branch?.trim() ?? "";
299
+ if (branch) {
300
+ const existing = findExistingWorktreeForBranch(branch);
301
+ if (existing) return existing;
302
+ die(
303
+ `Worktree for task '${taskId}' is expected but missing.
304
+ Looked for ${directWorktree} and a worktree for branch '${branch}'.
305
+ Restore or recreate the worktree before continuing.`
306
+ );
307
+ }
308
+ const scan = scanWorktreesForSecondaryOwnership(taskId);
309
+ switch (scan.outcome) {
310
+ case "matched":
311
+ return scan.worktreePath;
312
+ case "ambiguous":
313
+ die(
314
+ `Multiple worktrees claim ownership of task '${taskId}':
315
+ ` + scan.worktreePaths.map((worktreePath) => ` - ${worktreePath}`).join("\n") + `
316
+ Only one worktree may record this task's branch. Resolve manually before continuing.`
317
+ );
318
+ break;
319
+ case "enumeration-failed":
320
+ die(`Could not enumerate git worktrees while resolving task '${taskId}' ('git worktree list --porcelain' failed).`);
321
+ break;
322
+ case "present-but-invalid":
323
+ die(
324
+ `Task '${taskId}' has an unreadable status.json in worktree ${scan.worktreePath}: ${scan.error}
325
+ Fix or remove that file before continuing \u2014 ownership cannot be determined.`
326
+ );
327
+ break;
328
+ case "no-match":
329
+ break;
330
+ }
331
+ }
332
+ } catch {
333
+ }
334
+ return REPO_ROOT;
335
+ }
336
+ function statusFileFor(taskId) {
337
+ if (process.env.CANON_TASKS_DIR_OVERRIDE) {
338
+ return path3.join(process.env.CANON_TASKS_DIR_OVERRIDE, taskId, "status.json");
339
+ }
340
+ return path3.join(resolveTaskCwd(taskId), "tasks", taskId, "status.json");
341
+ }
342
+ function validateBranchField(value, taskId, fieldName) {
343
+ if (value === void 0) return;
344
+ if (typeof value !== "string") {
345
+ throw new Error(`Invalid ${fieldName} in task '${taskId}': expected string, got ${typeof value}. Edit status.json.`);
346
+ }
347
+ const trimmed = value.trim();
348
+ if (trimmed === "") return;
349
+ if (trimmed.startsWith("-")) {
350
+ throw new Error(`Invalid ${fieldName} in task '${taskId}': '${value}' looks like a flag, not a branch name. Edit status.json.`);
351
+ }
352
+ if (/[\x00-\x1F\x7F\s:]/.test(trimmed)) {
353
+ throw new Error(`Invalid ${fieldName} in task '${taskId}': '${value}' contains control chars, whitespace, or refspec separator. Edit status.json.`);
354
+ }
355
+ }
356
+ function validateNonNegativeInt(value, taskId, fieldPath) {
357
+ if (value === void 0) return;
358
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
359
+ throw new Error(`Invalid ${fieldPath} in task '${taskId}': expected non-negative integer, got ${JSON.stringify(value)}. Edit status.json.`);
360
+ }
361
+ }
362
+ function validateStatus(taskId, parsed) {
363
+ validateBranchField(parsed.branch, taskId, "branch");
364
+ validateBranchField(parsed.base_branch, taskId, "base_branch");
365
+ const phases = parsed.phases ?? {};
366
+ for (const [phaseName, entry] of Object.entries(phases)) {
367
+ if (!entry) continue;
368
+ for (const field of ["iterations", "iterations_current_loop", "iterations_total", "changes_requested_total", "preflight_rejections_current_loop", "preflight_rejections_total", "auto_block_count", "reroute_count"]) {
369
+ validateNonNegativeInt(entry[field], taskId, `phases.${phaseName}.${field}`);
370
+ }
371
+ }
372
+ }
373
+ function readStatusFromPath(statusFile, taskIdForErrors = "<unknown>") {
374
+ const parsed = JSON.parse(fs4.readFileSync(statusFile, "utf8"));
375
+ validateStatus(taskIdForErrors, parsed);
376
+ return parsed;
377
+ }
378
+ function deriveTopLevelStatus(status) {
379
+ for (const phase of PHASE_ORDER) {
380
+ const phaseStatus = status.phases[phase]?.status ?? "pending";
381
+ if (phaseStatus !== "done") return phase;
382
+ }
383
+ return "complete";
384
+ }
385
+
386
+ // src/orchestrator/git.ts
387
+ function gitSafeAt(cwd, ...args2) {
388
+ const result = spawnSync3("git", args2, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
389
+ if (result.error) return { ok: false, stdout: "", stderr: result.error.message };
390
+ return { ok: result.status === 0, stdout: (result.stdout ?? "").trim(), stderr: (result.stderr ?? "").trim() };
391
+ }
392
+ function filterGitIgnoredPaths(paths, cwd) {
393
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
394
+ const result = spawnSync3("git", ["check-ignore", "--stdin", "-z"], {
395
+ cwd,
396
+ input: `${paths.join("\0")}\0`,
397
+ encoding: "utf8",
398
+ stdio: ["pipe", "pipe", "pipe"]
399
+ });
400
+ if (result.error || result.status !== 0 && result.status !== 1) {
401
+ return /* @__PURE__ */ new Set();
402
+ }
403
+ const stdout = result.stdout ?? "";
404
+ return new Set(stdout.split("\0").filter((p) => p.length > 0));
405
+ }
406
+
407
+ // src/orchestrator/worktree.ts
408
+ var PIPELINE_TELEMETRY_FILES = [
409
+ "docs/pipeline-invocations.md",
410
+ "docs/task-quality-log.md",
411
+ "docs/lessons-learned.md"
412
+ ];
413
+
414
+ // src/orchestrator/docs-freshness.ts
415
+ var DOCS_FRESHNESS_CONFIG_PATH = ".canon/config.json";
416
+ var DEFAULT_PROTECTED_DOCS = [
417
+ "docs/architecture.md",
418
+ "docs/codebase-map.md",
419
+ "docs/patterns.md",
420
+ "docs/product-context.md",
421
+ "docs/decisions.md"
422
+ ];
423
+ function invalid(problem) {
424
+ return { ok: false, message: `${DOCS_FRESHNESS_CONFIG_PATH} is invalid: ${problem} Fix the file (or delete it to use canon's defaults), then re-run.` };
425
+ }
426
+ function isObject(value) {
427
+ return typeof value === "object" && value !== null && !Array.isArray(value);
428
+ }
429
+ function isStrings(value) {
430
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.trim().length > 0);
431
+ }
432
+ function isPipelinePath(value) {
433
+ return value === "tasks" || value.startsWith("tasks/") || PIPELINE_TELEMETRY_FILES.includes(value);
434
+ }
435
+ function docProblem(doc, key) {
436
+ if (path6.posix.isAbsolute(doc) || path6.win32.isAbsolute(doc) || doc.split(/[\\/]/).includes("..")) {
437
+ return `doc path "${doc}" in ${key} must be repo-relative with no ".." segment.`;
438
+ }
439
+ if (/[*?[\]{}]/.test(doc)) return `doc path "${doc}" in ${key} must not contain glob characters.`;
440
+ if (doc.startsWith(":")) return `doc path "${doc}" in ${key} must not start with ":".`;
441
+ if (["", "."].includes(doc.split(/[\\/]/).at(-1) ?? "")) {
442
+ return `doc path "${doc}" in ${key} must name a file, not a directory.`;
443
+ }
444
+ if (isPipelinePath(path6.posix.normalize(doc.replaceAll("\\", "/")))) {
445
+ return `doc path "${doc}" in ${key} is a pipeline telemetry file or task artifact; telemetry files and tasks/ paths can't be configured.`;
446
+ }
447
+ return null;
448
+ }
449
+ function parseDocsFreshnessConfig(raw) {
450
+ let value;
451
+ try {
452
+ value = JSON.parse(raw);
453
+ } catch (error) {
454
+ return invalid(`not valid JSON (${error instanceof Error ? error.message : String(error)}).`);
455
+ }
456
+ if (!isObject(value)) return invalid("the top level must be a JSON object.");
457
+ const unknownKeys = Object.keys(value).filter((key) => key !== "docs_freshness");
458
+ if (!Object.hasOwn(value, "docs_freshness")) return { ok: true, source: "file", config: null, unknownKeys };
459
+ const docs = value.docs_freshness;
460
+ if (!isObject(docs)) return invalid('"docs_freshness" must be an object.');
461
+ unknownKeys.push(...Object.keys(docs).filter((key) => !["protected", "paths", "flag_only"].includes(key)).map((key) => `docs_freshness.${key}`));
462
+ const config2 = {};
463
+ for (const key of ["protected", "flag_only"]) {
464
+ if (!Object.hasOwn(docs, key)) continue;
465
+ const list = docs[key];
466
+ if (!isStrings(list)) return invalid(`"docs_freshness.${key}" must be an array of non-empty strings.`);
467
+ for (const doc of list) {
468
+ const problem = docProblem(doc, `docs_freshness.${key}`);
469
+ if (problem) return invalid(problem);
470
+ }
471
+ config2[key === "protected" ? "protected" : "flagOnly"] = list.map((doc) => path6.posix.normalize(doc.replaceAll("\\", "/")));
472
+ }
473
+ if (Object.hasOwn(docs, "paths")) {
474
+ if (!isObject(docs.paths)) return invalid('"docs_freshness.paths" must be an object mapping globs to arrays of doc paths.');
475
+ const mappings = /* @__PURE__ */ Object.create(null);
476
+ for (const [glob, list] of Object.entries(docs.paths)) {
477
+ if (!glob.trim() || path6.win32.isAbsolute(glob) || glob.startsWith("/") || glob.startsWith("./") || glob.split(/[\\/]/).includes("..")) {
478
+ return invalid(`paths key "${glob}" must be a non-empty repo-relative glob that does not start with "/" or "./" and has no ".." segment.`);
479
+ }
480
+ if (!isStrings(list)) return invalid(`docs_freshness.paths["${glob}"] must be an array of non-empty strings.`);
481
+ for (const doc of list) {
482
+ const problem = docProblem(doc, `docs_freshness.paths["${glob}"]`);
483
+ if (problem) return invalid(problem);
484
+ }
485
+ mappings[glob] = list.map((doc) => path6.posix.normalize(doc.replaceAll("\\", "/")));
486
+ }
487
+ config2.paths = mappings;
488
+ }
489
+ return { ok: true, source: "file", config: config2, unknownKeys };
490
+ }
491
+ function loadDocsFreshnessConfig(root) {
492
+ try {
493
+ return parseDocsFreshnessConfig(fs7.readFileSync(path6.join(root, DOCS_FRESHNESS_CONFIG_PATH), "utf8"));
494
+ } catch (error) {
495
+ if (isObject(error) && error.code === "ENOENT") return { ok: true, source: "defaults", config: null, unknownKeys: [] };
496
+ return invalid(`could not be read (${error instanceof Error ? error.message : String(error)}).`);
497
+ }
498
+ }
499
+ function formatUnknownKeysWarning(keys) {
500
+ return `${DOCS_FRESHNESS_CONFIG_PATH}: unknown key(s) ignored: ${keys.join(", ")}. Check for a misspelled field (for example docs_freshness).`;
501
+ }
502
+ function tokenizeSegment(segment) {
503
+ const chars = [...segment];
504
+ const tokens = [];
505
+ for (let i = 0; i < chars.length; i++) {
506
+ const close = chars[i] === "{" ? chars.indexOf("}", i) : -1;
507
+ if (close < 0) {
508
+ tokens.push(chars[i]);
509
+ continue;
510
+ }
511
+ tokens.push(chars.slice(i + 1, close).join("").split(",").map((alt) => [...alt]));
512
+ i = close;
513
+ }
514
+ return tokens;
515
+ }
516
+ function advance(token, from, name) {
517
+ const next = /* @__PURE__ */ new Set();
518
+ if (token === "*") {
519
+ if (from.size) for (let n = Math.min(...from); n <= name.length; n++) next.add(n);
520
+ } else {
521
+ for (const n of from) if (n < name.length && (token === "?" || name[n] === token)) next.add(n + 1);
522
+ }
523
+ return next;
524
+ }
525
+ function matchesSegment(tokens, segmentName) {
526
+ const name = [...segmentName];
527
+ let offsets = /* @__PURE__ */ new Set([0]);
528
+ for (const token of tokens) {
529
+ if (typeof token === "string") offsets = advance(token, offsets, name);
530
+ else {
531
+ const merged = /* @__PURE__ */ new Set();
532
+ for (const alt of token) {
533
+ let altOffsets = offsets;
534
+ for (const char of alt) altOffsets = advance(char, altOffsets, name);
535
+ for (const n of altOffsets) merged.add(n);
536
+ }
537
+ offsets = merged;
538
+ }
539
+ if (!offsets.size) return false;
540
+ }
541
+ return offsets.has(name.length);
542
+ }
543
+ function matchesDocsGlob(pattern, filePath) {
544
+ const globs = pattern.split("/").filter((segment, i, parts) => segment !== "**" || parts[i - 1] !== "**").map((segment) => segment === "**" ? null : tokenizeSegment(segment));
545
+ const names = filePath.split("/");
546
+ const memo = /* @__PURE__ */ new Map();
547
+ const match = (g, n) => {
548
+ const key = g * (names.length + 1) + n;
549
+ const cached = memo.get(key);
550
+ if (cached !== void 0) return cached;
551
+ let result;
552
+ if (g === globs.length) result = n === names.length;
553
+ else {
554
+ const tokens = globs[g];
555
+ if (tokens === null) {
556
+ result = g === globs.length - 1 ? n < names.length : match(g + 1, n) || n < names.length && names[n] !== "" && match(g, n + 1);
557
+ } else {
558
+ result = n < names.length && matchesSegment(tokens, names[n]) && match(g + 1, n + 1);
559
+ }
560
+ }
561
+ memo.set(key, result);
562
+ return result;
563
+ };
564
+ return match(0, 0);
565
+ }
566
+ function resolveDocsFreshness(config2, diff) {
567
+ const protectedDocs = [...new Set(config2?.protected ?? DEFAULT_PROTECTED_DOCS)];
568
+ const mappings = Object.entries(config2?.paths ?? {});
569
+ const mapped = [...new Set(mappings.flatMap(([, docs]) => [...docs]))];
570
+ const flagOnly = [...new Set(config2?.flagOnly ?? [])];
571
+ const prompt = new Set(protectedDocs);
572
+ const paths = diff?.filter((file) => !isPipelinePath(file)) ?? [];
573
+ for (const [glob, docs] of mappings) {
574
+ if (paths.some((file) => matchesDocsGlob(glob, file))) for (const doc of docs) prompt.add(doc);
575
+ }
576
+ return {
577
+ protected: protectedDocs,
578
+ flagOnly,
579
+ admitted: [.../* @__PURE__ */ new Set([...protectedDocs, ...mapped])].filter((doc) => !flagOnly.includes(doc)),
580
+ configured: [.../* @__PURE__ */ new Set([...protectedDocs, ...mapped, ...flagOnly])],
581
+ promptEditable: [...prompt].filter((doc) => !flagOnly.includes(doc)),
582
+ promptFlagged: [...prompt].filter((doc) => flagOnly.includes(doc)),
583
+ unevaluatedMappedDocs: diff === null ? mapped.filter((doc) => !protectedDocs.includes(doc)) : []
584
+ };
585
+ }
586
+
587
+ // src/cli/commands/doctor.ts
588
+ import { execSync, spawnSync as spawnSync5 } from "child_process";
589
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
590
+ import { homedir } from "os";
591
+ import { dirname, join, relative, sep as pathSep } from "path";
592
+
593
+ // src/orchestrator/quality-log.ts
594
+ import fs8 from "fs";
595
+ import path7 from "path";
596
+
597
+ // src/orchestrator/markdown-table.ts
598
+ function splitTableLine(line) {
599
+ const cells = [];
600
+ let cell = "";
601
+ let backslashes = 0;
602
+ for (const char of line) {
603
+ if (char === "\\") {
604
+ backslashes += 1;
605
+ continue;
606
+ }
607
+ if (char === "|") {
608
+ if (backslashes % 2 === 1) {
609
+ cell += "\\".repeat((backslashes - 1) / 2) + "|";
610
+ } else {
611
+ cell += "\\".repeat(backslashes / 2);
612
+ cells.push(cell);
613
+ cell = "";
614
+ }
615
+ backslashes = 0;
616
+ continue;
617
+ }
618
+ if (backslashes > 0) {
619
+ cell += "\\".repeat(backslashes);
620
+ backslashes = 0;
621
+ }
622
+ cell += char;
623
+ }
624
+ if (backslashes > 0) cell += "\\".repeat(backslashes);
625
+ cells.push(cell);
626
+ return cells;
627
+ }
628
+ function normalizeCells(line) {
629
+ const cells = splitTableLine(line.trim());
630
+ const innerCells = cells.slice(
631
+ (cells[0] ?? "").trim() === "" ? 1 : 0,
632
+ (cells[cells.length - 1] ?? "").trim() === "" ? -1 : void 0
633
+ );
634
+ return innerCells.map((cell) => cell.trim());
635
+ }
636
+ function isSeparatorRow(cells) {
637
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell.trim()));
638
+ }
639
+ function isSectionHeading(line, sectionHeading) {
640
+ return line.trimEnd() === `## ${sectionHeading}`;
641
+ }
642
+ function isHeadingBoundary(line) {
114
643
  return /^#{1,2}\s/.test(line);
115
644
  }
116
645
  function computeCommentHiddenLines(lines) {
@@ -269,7 +798,7 @@ var JUDGMENT_LABELS = {
269
798
  "notes": "Notes"
270
799
  };
271
800
  function getQualityLogFile(activeCwd) {
272
- return process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path2.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path2.join(activeCwd, "docs/task-quality-log.md");
801
+ return process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path7.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path7.join(activeCwd, "docs/task-quality-log.md");
273
802
  }
274
803
  function normalizeCellValue(value) {
275
804
  return value.replace(/\r\n|\n/g, " ");
@@ -419,443 +948,128 @@ function renderRowLine(headerCells, row) {
419
948
  const cells = headerCells.map((header) => serializeQualityLogCell(row[header] ?? ""));
420
949
  return `| ${cells.join(" | ")} |`;
421
950
  }
422
- function writeFileAtomic(filePath, content) {
423
- const tempPath = `${filePath}.tmp`;
424
- try {
425
- fs3.writeFileSync(tempPath, content, "utf8");
426
- fs3.renameSync(tempPath, filePath);
427
- } finally {
428
- try {
429
- fs3.unlinkSync(tempPath);
430
- } catch {
431
- }
432
- }
433
- }
434
- function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
435
- try {
436
- let content;
437
- try {
438
- content = fs3.readFileSync(logFilePath, "utf8");
439
- } catch (error) {
440
- if (error.code === "ENOENT") {
441
- content = STANDARD_QUALITY_LOG_SKELETON;
442
- } else {
443
- const message = error instanceof Error ? error.message : String(error);
444
- warn(`quality-log: could not read ${logFilePath}: ${message}`);
445
- return;
446
- }
447
- }
448
- const lines = content.split("\n");
449
- const located = locateLogTable(lines);
450
- if (!located) {
451
- warn(
452
- `quality-log: ${logFilePath} has no well-formed '## Log' table with all required columns \u2014 skipping row write for '${derived.taskId}'.`
453
- );
454
- return;
455
- }
456
- const logRows = parseLogRows(
457
- lines,
458
- located.headerCells,
459
- located.dataStart,
460
- located.dataEnd
461
- );
462
- const strayRows = parseStrayRows(lines, located.headerCells);
463
- const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === derived.taskId);
464
- const reconciled = reconcileHistory(taskRows, located.headerCells);
465
- const finalRow = buildFinalRow(located.headerCells, derived, reconciled, qaSupplied);
466
- const rendered = renderRowLine(located.headerCells, finalRow);
467
- const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
468
- const updatedLines = [];
469
- for (let index = 0; index < lines.length; index += 1) {
470
- if (index === located.dataEnd) updatedLines.push(rendered);
471
- if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
472
- }
473
- if (located.dataEnd >= lines.length) updatedLines.push(rendered);
474
- writeFileAtomic(logFilePath, updatedLines.join("\n"));
475
- } catch (error) {
476
- const message = error instanceof Error ? error.message : String(error);
477
- warn(`quality-log: unexpected error writing row for '${derived.taskId}': ${message}`);
478
- }
479
- }
480
- function parseQualityLogJudgmentBlock(doneMdContent) {
481
- const bodies = extractSectionBodies(doneMdContent, /^## Quality Log\b/);
482
- if (bodies.length === 0) return {};
483
- const result = {};
484
- for (const line of bodies[bodies.length - 1].split("\n")) {
485
- const match = /^-\s*([^:]+):\s*(.*)$/.exec(line.trim());
486
- if (!match) continue;
487
- const key = JUDGMENT_LABELS[match[1].trim().toLowerCase()];
488
- const value = match[2].trim();
489
- if (key && value) result[key] = value;
490
- }
491
- return result;
492
- }
493
- function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
494
- try {
495
- let doneContent = "";
496
- try {
497
- doneContent = fs3.readFileSync(donePath, "utf8");
498
- } catch {
499
- }
500
- upsertQualityLogRow(
501
- getQualityLogFile(activeCwd),
502
- {
503
- taskId,
504
- taskSize: status.task_size,
505
- delicate: status.delicate,
506
- specIterTotal: status.phases.spec_review?.iterations_total ?? 0,
507
- reviewIterTotal: status.phases.code_review?.iterations_total ?? 0
508
- },
509
- parseQualityLogJudgmentBlock(doneContent)
510
- );
511
- } catch (error) {
512
- const message = error instanceof Error ? error.message : String(error);
513
- warn(`quality-log: failed to write row for '${taskId}': ${message}`);
514
- }
515
- }
516
-
517
- // src/orchestrator/run-context.ts
518
- import path6 from "path";
519
-
520
- // src/orchestrator/detach.ts
521
- import { spawn } from "child_process";
522
- import fs4 from "fs";
523
- import path3 from "path";
524
- var PID_FILENAME = ".canon-pid";
525
- var LOG_FILENAME = ".canon-run.log";
526
- function readCanonPid(taskDir) {
527
- const file = path3.join(taskDir, PID_FILENAME);
528
- try {
529
- const raw = fs4.readFileSync(file, "utf8").trim();
530
- const pid = Number.parseInt(raw, 10);
531
- return Number.isInteger(pid) && pid > 0 ? pid : null;
532
- } catch {
533
- return null;
534
- }
535
- }
536
- function removeCanonPid(taskDir) {
537
- try {
538
- fs4.unlinkSync(path3.join(taskDir, PID_FILENAME));
539
- } catch {
540
- }
541
- }
542
- function runLogPathFor(taskDir) {
543
- return path3.join(taskDir, LOG_FILENAME);
544
- }
545
-
546
- // src/orchestrator/state.ts
547
- import fs6 from "fs";
548
- import { spawnSync as spawnSync2 } from "child_process";
549
- import path5 from "path";
550
-
551
- // src/orchestrator/env.ts
552
- import { spawnSync } from "child_process";
553
- import fs5 from "fs";
554
- import path4 from "path";
555
- import { fileURLToPath } from "url";
556
- var __filename = fileURLToPath(import.meta.url);
557
- var __dirname = path4.dirname(__filename);
558
- function resolveRepoRoot() {
559
- try {
560
- const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
561
- if (result.error || result.status !== 0) {
562
- throw result.error ?? new Error(result.stderr || "git rev-parse --git-common-dir failed");
563
- }
564
- const gitCommonDir = result.stdout.trim();
565
- if (!gitCommonDir) throw new Error("git rev-parse --git-common-dir returned no path");
566
- const resolvedGitCommonDir = path4.isAbsolute(gitCommonDir) ? gitCommonDir : path4.resolve(process.cwd(), gitCommonDir);
567
- return path4.dirname(resolvedGitCommonDir);
568
- } catch {
569
- return path4.resolve(__dirname, "../..");
570
- }
571
- }
572
- var REPO_ROOT = resolveRepoRoot();
573
- var TASKS_DIR = path4.join(REPO_ROOT, "tasks");
574
- var WORKTREES_ROOT = process.env.CANON_WORKTREES_ROOT ? path4.resolve(REPO_ROOT, process.env.CANON_WORKTREES_ROOT) : path4.resolve(REPO_ROOT, ".canon/worktrees");
575
- var STALL_TIMEOUT_MS = Number(process.env.PIPELINE_STALL_TIMEOUT_MS) || 10 * 60 * 1e3;
576
- function resolveProjectName() {
577
- if (process.env.CANON_PROJECT_NAME) return process.env.CANON_PROJECT_NAME;
578
- try {
579
- const pkgPath = path4.join(REPO_ROOT, "package.json");
580
- if (fs5.existsSync(pkgPath)) {
581
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf8"));
582
- if (pkg.name) return pkg.name;
583
- }
584
- } catch {
585
- }
586
- return "your project";
587
- }
588
- var config = {
589
- projectName: resolveProjectName(),
590
- maxContextBytes: Number.parseInt(process.env.MAX_CONTEXT_BYTES ?? String(64 * 1024), 10)
591
- };
592
-
593
- // src/orchestrator/types.ts
594
- var PHASE_ORDER = ["spec", "spec_review", "plan", "implement", "code_review", "qa", "human_review"];
595
-
596
- // src/orchestrator/state.ts
597
- function effectiveWorktreesRoot() {
598
- return process.env.CANON_WORKTREES_ROOT ? path5.resolve(REPO_ROOT, process.env.CANON_WORKTREES_ROOT) : WORKTREES_ROOT;
599
- }
600
- function isPathInside(child, parent) {
601
- if (child === parent) return true;
602
- const rel = path5.relative(parent, child);
603
- return rel !== "" && !rel.startsWith("..") && !path5.isAbsolute(rel);
604
- }
605
- function classifyInvocationRoot(params) {
606
- const { activeToplevel, mainRoot, worktreesRoot } = params;
607
- if (!activeToplevel) return { kind: "unknown" };
608
- if (activeToplevel === mainRoot) return { kind: "main" };
609
- if (isPathInside(activeToplevel, worktreesRoot)) {
610
- return { kind: "canon-worktree", activeRoot: activeToplevel };
611
- }
612
- return { kind: "foreign-worktree", activeRoot: activeToplevel, mainRoot, worktreesRoot };
613
- }
614
- function canonicalizePath(p) {
615
- try {
616
- return fs6.realpathSync(p);
617
- } catch {
618
- return p;
619
- }
620
- }
621
- function readActiveToplevel() {
622
- const result = spawnSync2("git", ["rev-parse", "--show-toplevel"], {
623
- encoding: "utf8",
624
- stdio: ["ignore", "pipe", "ignore"]
625
- });
626
- if (result.error || result.status !== 0) return null;
627
- const out = (result.stdout ?? "").trim();
628
- return out === "" ? null : out;
629
- }
630
- function assertManagedInvocationRoot() {
631
- if (process.env.CANON_TASKS_DIR_OVERRIDE) return;
632
- const activeToplevel = readActiveToplevel();
633
- const classification = classifyInvocationRoot({
634
- activeToplevel: activeToplevel ? canonicalizePath(activeToplevel) : null,
635
- mainRoot: canonicalizePath(REPO_ROOT),
636
- worktreesRoot: canonicalizePath(effectiveWorktreesRoot())
637
- });
638
- if (classification.kind !== "foreign-worktree") return;
639
- die(
640
- `Canon was invoked from a linked git worktree it does not manage:
641
- ${classification.activeRoot}
642
-
643
- Canon manages task worktrees itself \u2014 it creates them under
644
- ${classification.worktreesRoot}
645
- and routes task state there automatically. Running task commands from a
646
- hand-created linked worktree is unsupported: task state would be written
647
- here but read from the main checkout, so dirty-state, base-branch, and
648
- worktree-safety checks would all evaluate the wrong tree.
649
-
650
- Run canon from the main checkout instead:
651
- ${classification.mainRoot}
652
- and let canon create the task's worktree. If you intend THIS directory to be
653
- canon's managed worktrees root, set CANON_WORKTREES_ROOT accordingly.
654
-
655
- This also covers a worktree canon itself created under an earlier default
656
- worktrees-root location \u2014 to migrate it, move the directory under the
657
- current root shown above and, from the main checkout, run
658
- \`git worktree repair <new path>\` (the path argument is required for a moved worktree).`
659
- );
660
- }
661
- function listWorktreesWithBranches() {
662
- const result = spawnSync2("git", ["worktree", "list", "--porcelain"], {
663
- cwd: REPO_ROOT,
664
- encoding: "utf8",
665
- stdio: ["ignore", "pipe", "pipe"]
666
- });
667
- if (result.error || result.status !== 0) {
668
- return {
669
- ok: false,
670
- stderr: result.error ? result.error.message : (result.stderr ?? "").trim()
671
- };
672
- }
673
- const worktrees = [];
674
- let currentPath = null;
675
- let currentBranch = null;
676
- const flush = () => {
677
- if (currentPath && currentPath !== REPO_ROOT) {
678
- worktrees.push({ path: currentPath, branch: currentBranch });
679
- }
680
- };
681
- for (const line of (result.stdout ?? "").split("\n")) {
682
- if (line.startsWith("worktree ")) {
683
- flush();
684
- currentPath = line.slice("worktree ".length).trim();
685
- currentBranch = null;
686
- } else if (line.startsWith("branch refs/heads/")) {
687
- currentBranch = line.slice("branch refs/heads/".length).trim();
688
- }
689
- }
690
- flush();
691
- return { ok: true, worktrees };
692
- }
693
- function scanWorktreesForSecondaryOwnership(taskId) {
694
- const enumeration = listWorktreesWithBranches();
695
- if (!enumeration.ok) return { outcome: "enumeration-failed" };
696
- const matches = [];
697
- for (const { path: worktreePath, branch: checkedOutBranch } of enumeration.worktrees) {
698
- const candidateStatusPath = path5.join(worktreePath, "tasks", taskId, "status.json");
699
- if (!fs6.existsSync(candidateStatusPath)) continue;
700
- let candidate;
701
- try {
702
- candidate = readStatusFromPath(candidateStatusPath, taskId);
703
- } catch (err) {
704
- return {
705
- outcome: "present-but-invalid",
706
- worktreePath,
707
- error: err instanceof Error ? err.message : String(err)
708
- };
709
- }
710
- if (candidate.worktree !== true || checkedOutBranch === null) continue;
711
- const candidateBranch = candidate.branch?.trim() ?? "";
712
- if (candidateBranch && candidateBranch === checkedOutBranch) {
713
- matches.push(worktreePath);
714
- }
715
- }
716
- if (matches.length === 1) return { outcome: "matched", worktreePath: matches[0] };
717
- if (matches.length >= 2) return { outcome: "ambiguous", worktreePaths: matches };
718
- return { outcome: "no-match" };
719
- }
720
- function findExistingWorktreeForBranch(branch) {
721
- const result = spawnSync2("git", ["worktree", "list", "--porcelain"], {
722
- cwd: REPO_ROOT,
723
- encoding: "utf8",
724
- stdio: ["ignore", "pipe", "pipe"]
725
- });
726
- if (result.error || result.status !== 0) return null;
727
- const lines = (result.stdout ?? "").split("\n");
728
- let currentPath = null;
729
- for (const line of lines) {
730
- if (line.startsWith("worktree ")) {
731
- currentPath = line.slice("worktree ".length).trim();
732
- } else if (line.startsWith("branch refs/heads/") && currentPath && currentPath !== REPO_ROOT) {
733
- const lineBranch = line.slice("branch refs/heads/".length).trim();
734
- if (lineBranch === branch) return currentPath;
735
- }
736
- }
737
- return null;
738
- }
739
- function taskDirForRepoRoot(taskId) {
740
- return path5.join(process.env.CANON_TASKS_DIR_OVERRIDE ?? TASKS_DIR, taskId);
741
- }
742
- function taskDirFor(taskId) {
743
- if (process.env.CANON_TASKS_DIR_OVERRIDE) {
744
- return path5.join(process.env.CANON_TASKS_DIR_OVERRIDE, taskId);
745
- }
746
- return path5.join(resolveTaskCwd(taskId), "tasks", taskId);
747
- }
748
- function isOrphanedWorktreeState(taskId) {
749
- const worktreesRoot = effectiveWorktreesRoot();
750
- const directWorktree = path5.join(worktreesRoot, taskId);
751
- const directStatus = path5.join(directWorktree, "tasks", taskId, "status.json");
752
- if (fs6.existsSync(directStatus)) return false;
753
- const statusPath = path5.join(taskDirForRepoRoot(taskId), "status.json");
951
+ function writeFileAtomic(filePath, content) {
952
+ const tempPath = `${filePath}.tmp`;
754
953
  try {
755
- const parsed = JSON.parse(fs6.readFileSync(statusPath, "utf8"));
756
- if (parsed.worktree !== true) return false;
757
- const branch = parsed.branch?.trim() ?? "";
758
- if (!branch) return false;
759
- return findExistingWorktreeForBranch(branch) === null;
760
- } catch {
761
- return false;
954
+ fs8.writeFileSync(tempPath, content, "utf8");
955
+ fs8.renameSync(tempPath, filePath);
956
+ } finally {
957
+ try {
958
+ fs8.unlinkSync(tempPath);
959
+ } catch {
960
+ }
762
961
  }
763
962
  }
764
- function resolveTaskCwd(taskId) {
765
- const worktreesRoot = effectiveWorktreesRoot();
766
- const directWorktree = path5.join(worktreesRoot, taskId);
767
- const directStatus = path5.join(directWorktree, "tasks", taskId, "status.json");
768
- if (fs6.existsSync(directStatus)) return directWorktree;
769
- const statusPath = path5.join(taskDirForRepoRoot(taskId), "status.json");
963
+ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
770
964
  try {
771
- const parsed = JSON.parse(fs6.readFileSync(statusPath, "utf8"));
772
- if (parsed.worktree === true) {
773
- const branch = parsed.branch?.trim() ?? "";
774
- if (branch) {
775
- const existing = findExistingWorktreeForBranch(branch);
776
- if (existing) return existing;
777
- die(
778
- `Worktree for task '${taskId}' is expected but missing.
779
- Looked for ${directWorktree} and a worktree for branch '${branch}'.
780
- Restore or recreate the worktree before continuing.`
781
- );
782
- }
783
- const scan = scanWorktreesForSecondaryOwnership(taskId);
784
- switch (scan.outcome) {
785
- case "matched":
786
- return scan.worktreePath;
787
- case "ambiguous":
788
- die(
789
- `Multiple worktrees claim ownership of task '${taskId}':
790
- ` + scan.worktreePaths.map((worktreePath) => ` - ${worktreePath}`).join("\n") + `
791
- Only one worktree may record this task's branch. Resolve manually before continuing.`
792
- );
793
- break;
794
- case "enumeration-failed":
795
- die(`Could not enumerate git worktrees while resolving task '${taskId}' ('git worktree list --porcelain' failed).`);
796
- break;
797
- case "present-but-invalid":
798
- die(
799
- `Task '${taskId}' has an unreadable status.json in worktree ${scan.worktreePath}: ${scan.error}
800
- Fix or remove that file before continuing \u2014 ownership cannot be determined.`
801
- );
802
- break;
803
- case "no-match":
804
- break;
965
+ let content;
966
+ try {
967
+ content = fs8.readFileSync(logFilePath, "utf8");
968
+ } catch (error) {
969
+ if (error.code === "ENOENT") {
970
+ content = STANDARD_QUALITY_LOG_SKELETON;
971
+ } else {
972
+ const message = error instanceof Error ? error.message : String(error);
973
+ warn(`quality-log: could not read ${logFilePath}: ${message}`);
974
+ return;
805
975
  }
806
976
  }
807
- } catch {
977
+ const lines = content.split("\n");
978
+ const located = locateLogTable(lines);
979
+ if (!located) {
980
+ warn(
981
+ `quality-log: ${logFilePath} has no well-formed '## Log' table with all required columns \u2014 skipping row write for '${derived.taskId}'.`
982
+ );
983
+ return;
984
+ }
985
+ const logRows = parseLogRows(
986
+ lines,
987
+ located.headerCells,
988
+ located.dataStart,
989
+ located.dataEnd
990
+ );
991
+ const strayRows = parseStrayRows(lines, located.headerCells);
992
+ const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === derived.taskId);
993
+ const reconciled = reconcileHistory(taskRows, located.headerCells);
994
+ const finalRow = buildFinalRow(located.headerCells, derived, reconciled, qaSupplied);
995
+ const rendered = renderRowLine(located.headerCells, finalRow);
996
+ const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
997
+ const updatedLines = [];
998
+ for (let index = 0; index < lines.length; index += 1) {
999
+ if (index === located.dataEnd) updatedLines.push(rendered);
1000
+ if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
1001
+ }
1002
+ if (located.dataEnd >= lines.length) updatedLines.push(rendered);
1003
+ writeFileAtomic(logFilePath, updatedLines.join("\n"));
1004
+ } catch (error) {
1005
+ const message = error instanceof Error ? error.message : String(error);
1006
+ warn(`quality-log: unexpected error writing row for '${derived.taskId}': ${message}`);
808
1007
  }
809
- return REPO_ROOT;
810
1008
  }
811
- function statusFileFor(taskId) {
812
- if (process.env.CANON_TASKS_DIR_OVERRIDE) {
813
- return path5.join(process.env.CANON_TASKS_DIR_OVERRIDE, taskId, "status.json");
1009
+ function parseQualityLogJudgmentBlock(doneMdContent) {
1010
+ const bodies = extractSectionBodies(doneMdContent, /^## Quality Log\b/);
1011
+ if (bodies.length === 0) return {};
1012
+ const result = {};
1013
+ for (const line of bodies[bodies.length - 1].split("\n")) {
1014
+ const match = /^-\s*([^:]+):\s*(.*)$/.exec(line.trim());
1015
+ if (!match) continue;
1016
+ const key = JUDGMENT_LABELS[match[1].trim().toLowerCase()];
1017
+ const value = match[2].trim();
1018
+ if (key && value) result[key] = value;
814
1019
  }
815
- return path5.join(resolveTaskCwd(taskId), "tasks", taskId, "status.json");
1020
+ return result;
816
1021
  }
817
- function validateBranchField(value, taskId, fieldName) {
818
- if (value === void 0) return;
819
- if (typeof value !== "string") {
820
- throw new Error(`Invalid ${fieldName} in task '${taskId}': expected string, got ${typeof value}. Edit status.json.`);
821
- }
822
- const trimmed = value.trim();
823
- if (trimmed === "") return;
824
- if (trimmed.startsWith("-")) {
825
- throw new Error(`Invalid ${fieldName} in task '${taskId}': '${value}' looks like a flag, not a branch name. Edit status.json.`);
826
- }
827
- if (/[\x00-\x1F\x7F\s:]/.test(trimmed)) {
828
- throw new Error(`Invalid ${fieldName} in task '${taskId}': '${value}' contains control chars, whitespace, or refspec separator. Edit status.json.`);
1022
+ function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
1023
+ try {
1024
+ let doneContent = "";
1025
+ try {
1026
+ doneContent = fs8.readFileSync(donePath, "utf8");
1027
+ } catch {
1028
+ }
1029
+ upsertQualityLogRow(
1030
+ getQualityLogFile(activeCwd),
1031
+ {
1032
+ taskId,
1033
+ taskSize: status.task_size,
1034
+ delicate: status.delicate,
1035
+ specIterTotal: status.phases.spec_review?.iterations_total ?? 0,
1036
+ reviewIterTotal: status.phases.code_review?.iterations_total ?? 0
1037
+ },
1038
+ parseQualityLogJudgmentBlock(doneContent)
1039
+ );
1040
+ } catch (error) {
1041
+ const message = error instanceof Error ? error.message : String(error);
1042
+ warn(`quality-log: failed to write row for '${taskId}': ${message}`);
829
1043
  }
830
1044
  }
831
- function validateNonNegativeInt(value, taskId, fieldPath) {
832
- if (value === void 0) return;
833
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
834
- throw new Error(`Invalid ${fieldPath} in task '${taskId}': expected non-negative integer, got ${JSON.stringify(value)}. Edit status.json.`);
1045
+
1046
+ // src/orchestrator/run-context.ts
1047
+ import path9 from "path";
1048
+
1049
+ // src/orchestrator/detach.ts
1050
+ import { spawn } from "child_process";
1051
+ import fs9 from "fs";
1052
+ import path8 from "path";
1053
+ var PID_FILENAME = ".canon-pid";
1054
+ var LOG_FILENAME = ".canon-run.log";
1055
+ function readCanonPid(taskDir) {
1056
+ const file = path8.join(taskDir, PID_FILENAME);
1057
+ try {
1058
+ const raw = fs9.readFileSync(file, "utf8").trim();
1059
+ const pid = Number.parseInt(raw, 10);
1060
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
1061
+ } catch {
1062
+ return null;
835
1063
  }
836
1064
  }
837
- function validateStatus(taskId, parsed) {
838
- validateBranchField(parsed.branch, taskId, "branch");
839
- validateBranchField(parsed.base_branch, taskId, "base_branch");
840
- const phases = parsed.phases ?? {};
841
- for (const [phaseName, entry] of Object.entries(phases)) {
842
- if (!entry) continue;
843
- for (const field of ["iterations", "iterations_current_loop", "iterations_total", "changes_requested_total", "preflight_rejections_current_loop", "preflight_rejections_total", "auto_block_count", "reroute_count"]) {
844
- validateNonNegativeInt(entry[field], taskId, `phases.${phaseName}.${field}`);
845
- }
1065
+ function removeCanonPid(taskDir) {
1066
+ try {
1067
+ fs9.unlinkSync(path8.join(taskDir, PID_FILENAME));
1068
+ } catch {
846
1069
  }
847
1070
  }
848
- function readStatusFromPath(statusFile, taskIdForErrors = "<unknown>") {
849
- const parsed = JSON.parse(fs6.readFileSync(statusFile, "utf8"));
850
- validateStatus(taskIdForErrors, parsed);
851
- return parsed;
852
- }
853
- function deriveTopLevelStatus(status) {
854
- for (const phase of PHASE_ORDER) {
855
- const phaseStatus = status.phases[phase]?.status ?? "pending";
856
- if (phaseStatus !== "done") return phase;
857
- }
858
- return "complete";
1071
+ function runLogPathFor(taskDir) {
1072
+ return path8.join(taskDir, LOG_FILENAME);
859
1073
  }
860
1074
 
861
1075
  // src/orchestrator/run-context.ts
@@ -878,7 +1092,7 @@ function statusReadResult(taskId, statusFile, readImpl) {
878
1092
  }
879
1093
  function tolerantTaskDir(taskId) {
880
1094
  if (isOrphanedWorktreeState(taskId)) return taskDirForRepoRoot(taskId);
881
- return path6.dirname(statusFileFor(taskId));
1095
+ return path9.dirname(statusFileFor(taskId));
882
1096
  }
883
1097
  function defaultProbeAlive(pid) {
884
1098
  process.kill(pid, 0);
@@ -906,8 +1120,8 @@ function probePidAlive(pid, probeImpl = defaultProbeAlive) {
906
1120
  }
907
1121
  function gatherRunContext(taskId, deps = {}) {
908
1122
  const taskDir = (deps.resolveTaskDirImpl ?? tolerantTaskDir)(taskId);
909
- const statusFile = path6.join(taskDir, "status.json");
910
- const heartbeatFile = path6.join(taskDir, ".heartbeat.json");
1123
+ const statusFile = path9.join(taskDir, "status.json");
1124
+ const heartbeatFile = path9.join(taskDir, ".heartbeat.json");
911
1125
  const statusResult = statusReadResult(taskId, statusFile, deps.readStatusImpl);
912
1126
  const heartbeatResult = (deps.readHeartbeatImpl ?? readHeartbeatStatus)(taskDir);
913
1127
  const canonPid = (deps.readCanonPidImpl ?? readCanonPid)(taskDir);
@@ -992,7 +1206,7 @@ function upsertCanonBlock(content, block) {
992
1206
  }
993
1207
 
994
1208
  // src/cli/deps.ts
995
- import { spawnSync as spawnSync3 } from "child_process";
1209
+ import { spawnSync as spawnSync4 } from "child_process";
996
1210
  var HARD_DEPS = [
997
1211
  { cmd: "git", installHint: "https://git-scm.com/downloads" },
998
1212
  { cmd: "claude", installHint: "npm install -g @anthropic-ai/claude-code" },
@@ -1002,7 +1216,7 @@ var SOFT_DEPS = [
1002
1216
  { cmd: "gh", installHint: "brew install gh && gh auth login (required for --pr / --push)" }
1003
1217
  ];
1004
1218
  function isAvailable(cmd) {
1005
- const result = spawnSync3("which", [cmd], { stdio: "ignore" });
1219
+ const result = spawnSync4("which", [cmd], { stdio: "ignore" });
1006
1220
  return result.status === 0;
1007
1221
  }
1008
1222
  function checkDeps() {
@@ -1186,8 +1400,8 @@ ${RECOMMENDED_NUDGE}`
1186
1400
  };
1187
1401
  }
1188
1402
  const mentionsCanon = existingFiles.some((filename) => {
1189
- const path15 = join(cwd, filename);
1190
- return /canon/i.test(readFileSync(path15, "utf8"));
1403
+ const path16 = join(cwd, filename);
1404
+ return /canon/i.test(readFileSync(path16, "utf8"));
1191
1405
  });
1192
1406
  if (mentionsCanon) {
1193
1407
  return { label: "canon discovery nudge", status: "pass" };
@@ -1224,7 +1438,7 @@ function checkTemplates(cwd) {
1224
1438
  }
1225
1439
  function checkCanonVersion(cwd) {
1226
1440
  const versionPath = join(cwd, ".canon", "version");
1227
- const installedVersion = "3.7.0";
1441
+ const installedVersion = "3.9.0";
1228
1442
  if (!existsSync(versionPath)) {
1229
1443
  return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
1230
1444
  }
@@ -1398,10 +1612,10 @@ function checkCodexProjectTrust(cwd) {
1398
1612
  trust_level = "trusted"`
1399
1613
  };
1400
1614
  }
1401
- function readAllowFromSettings(path15) {
1402
- if (!existsSync(path15)) return { allow: /* @__PURE__ */ new Set(), status: "missing" };
1615
+ function readAllowFromSettings(path16) {
1616
+ if (!existsSync(path16)) return { allow: /* @__PURE__ */ new Set(), status: "missing" };
1403
1617
  try {
1404
- const parsed = JSON.parse(readFileSync(path15, "utf8"));
1618
+ const parsed = JSON.parse(readFileSync(path16, "utf8"));
1405
1619
  const raw = parsed?.permissions?.allow;
1406
1620
  const allow = new Set(
1407
1621
  Array.isArray(raw) ? raw.filter((x) => typeof x === "string") : []
@@ -1551,6 +1765,33 @@ function printCheck(c) {
1551
1765
  const line = ` ${icon} ${c.label}`;
1552
1766
  console.log(c.detail ? `${line} \u2014 ${c.detail}` : line);
1553
1767
  }
1768
+ function checkDocsFreshness(cwd) {
1769
+ const label = "Docs freshness";
1770
+ const loaded = loadDocsFreshnessConfig(cwd);
1771
+ if (!loaded.ok) return { label, status: "fail", detail: loaded.message };
1772
+ if (loaded.source === "defaults") return { label, status: "pass", detail: "canon defaults (no config file)" };
1773
+ const sets = resolveDocsFreshness(loaded.config, null);
1774
+ const warnings = [];
1775
+ if (loaded.unknownKeys.length) warnings.push(formatUnknownKeysWarning(loaded.unknownKeys));
1776
+ for (const doc of sets.configured) {
1777
+ if (!existsSync(join(cwd, doc))) warnings.push(`configured doc missing: ${doc}`);
1778
+ else if (statSync(join(cwd, doc)).isDirectory()) warnings.push(`configured doc is a directory, not a file: ${doc}`);
1779
+ }
1780
+ const globs = Object.keys(loaded.config?.paths ?? {});
1781
+ if (globs.length) {
1782
+ const tracked = spawnSync5("git", ["ls-files", "-z"], { cwd, encoding: "utf8" });
1783
+ if (tracked.status !== 0 || tracked.error) warnings.push("could not list tracked files to check globs");
1784
+ else {
1785
+ const files = tracked.stdout.split("\0").filter(Boolean);
1786
+ for (const glob of globs) {
1787
+ if (!files.some((file) => matchesDocsGlob(glob, file))) warnings.push(`glob matches no tracked file: ${glob}`);
1788
+ }
1789
+ }
1790
+ }
1791
+ const source = loaded.config === null ? ".canon/config.json (no docs_freshness; canon defaults)" : ".canon/config.json";
1792
+ const detail = `${source}: ${sets.protected.length} protected, ${globs.length} mappings, ${sets.flagOnly.length} flag-only`;
1793
+ return { label, status: warnings.length ? "warn" : "pass", detail: [detail, ...warnings].join("; ") };
1794
+ }
1554
1795
  function doctorCmd(_args) {
1555
1796
  const cwd = process.cwd();
1556
1797
  const envChecks = [
@@ -1575,7 +1816,8 @@ function doctorCmd(_args) {
1575
1816
  checkCodexProjectTrust(cwd),
1576
1817
  checkRecommendedPermissions(cwd),
1577
1818
  checkLocalSettingsGitignored(cwd),
1578
- checkRuntimeFilesGitignored(cwd)
1819
+ checkRuntimeFilesGitignored(cwd),
1820
+ checkDocsFreshness(cwd)
1579
1821
  ];
1580
1822
  const orchestratorChecks = checkActiveOrchestrators(cwd);
1581
1823
  console.log("\ncanon doctor\n");
@@ -1613,7 +1855,7 @@ import {
1613
1855
  readFileSync as readFileSync2,
1614
1856
  mkdirSync,
1615
1857
  readdirSync as readdirSync2,
1616
- statSync,
1858
+ statSync as statSync2,
1617
1859
  writeFileSync
1618
1860
  } from "fs";
1619
1861
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -1621,12 +1863,12 @@ import { dirname as dirname2, join as join2, relative as relative2 } from "path"
1621
1863
 
1622
1864
  // src/lib/scaffold-paths.ts
1623
1865
  import { lstatSync } from "fs";
1624
- import path7 from "path";
1866
+ import path10 from "path";
1625
1867
  function assertNoSymlinkDestinations(root, relativePaths) {
1626
1868
  for (const relativePath of relativePaths) {
1627
1869
  let current = root;
1628
1870
  for (const segment of relativePath.split(/[\\/]/)) {
1629
- current = path7.join(current, segment);
1871
+ current = path10.join(current, segment);
1630
1872
  let entry;
1631
1873
  try {
1632
1874
  entry = lstatSync(current);
@@ -1659,7 +1901,7 @@ function walkDir(dir, base = dir) {
1659
1901
  const results = [];
1660
1902
  for (const entry of readdirSync2(dir)) {
1661
1903
  const full = join2(dir, entry);
1662
- if (statSync(full).isDirectory()) {
1904
+ if (statSync2(full).isDirectory()) {
1663
1905
  results.push(...walkDir(full, base));
1664
1906
  } else {
1665
1907
  results.push(relative2(base, full));
@@ -1721,7 +1963,7 @@ function initCmd(_args) {
1721
1963
  }
1722
1964
  function writeCanonVersion(cwd) {
1723
1965
  const versionPath = join2(cwd, ".canon", "version");
1724
- const version = "3.7.0";
1966
+ const version = "3.9.0";
1725
1967
  mkdirSync(dirname2(versionPath), { recursive: true });
1726
1968
  writeFileSync(versionPath, version + "\n");
1727
1969
  }
@@ -1743,7 +1985,7 @@ function launchGrill(cwd, hasExistingAgentFiles2) {
1743
1985
  }
1744
1986
 
1745
1987
  // src/cli/commands/run-task.ts
1746
- import { spawnSync as spawnSync4 } from "child_process";
1988
+ import { spawnSync as spawnSync6 } from "child_process";
1747
1989
  import { fileURLToPath as fileURLToPath3 } from "url";
1748
1990
  import { dirname as dirname3, join as join3 } from "path";
1749
1991
  var packageDir2 = join3(dirname3(fileURLToPath3(import.meta.url)), "../..");
@@ -1752,7 +1994,7 @@ function runCmd(args2) {
1752
1994
  for (const arg of args2) {
1753
1995
  checkDepForFlag(arg);
1754
1996
  }
1755
- const result = spawnSync4(process.execPath, [runTaskScript, ...args2], {
1997
+ const result = spawnSync6(process.execPath, [runTaskScript, ...args2], {
1756
1998
  stdio: "inherit",
1757
1999
  cwd: process.cwd()
1758
2000
  });
@@ -1760,7 +2002,7 @@ function runCmd(args2) {
1760
2002
  }
1761
2003
 
1762
2004
  // src/cli/commands/watch.ts
1763
- import fs7 from "fs";
2005
+ import fs10 from "fs";
1764
2006
  import { StringDecoder } from "string_decoder";
1765
2007
 
1766
2008
  // src/cli/commands/stop.ts
@@ -2371,8 +2613,8 @@ function tailRunLog(ctx, taskId, deps, tailState) {
2371
2613
  const logPath = runLogPathFor(logTaskDir);
2372
2614
  let fd;
2373
2615
  try {
2374
- fd = fs7.openSync(logPath, "r");
2375
- const stat = fs7.fstatSync(fd);
2616
+ fd = fs10.openSync(logPath, "r");
2617
+ const stat = fs10.fstatSync(fd);
2376
2618
  const identity = `${logPath}:${stat.dev}:${stat.ino}`;
2377
2619
  if (tailState.position == null || tailState.path !== logPath) {
2378
2620
  tailState.position = stat.size;
@@ -2389,7 +2631,7 @@ function tailRunLog(ctx, taskId, deps, tailState) {
2389
2631
  if (stat.size === tailState.position) return;
2390
2632
  const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, stat.size - tailState.position));
2391
2633
  while (tailState.position < stat.size) {
2392
- const bytes = fs7.readSync(fd, buffer, 0, Math.min(buffer.length, stat.size - tailState.position), tailState.position);
2634
+ const bytes = fs10.readSync(fd, buffer, 0, Math.min(buffer.length, stat.size - tailState.position), tailState.position);
2393
2635
  if (bytes === 0) break;
2394
2636
  tailState.position += bytes;
2395
2637
  const chunk = tailState.decoder.write(buffer.subarray(0, bytes));
@@ -2401,7 +2643,7 @@ function tailRunLog(ctx, taskId, deps, tailState) {
2401
2643
  deps.stderr?.(`canon watch: failed to tail ${logPath}: ${errorMessage2(error)}
2402
2644
  `);
2403
2645
  } finally {
2404
- if (fd !== void 0) fs7.closeSync(fd);
2646
+ if (fd !== void 0) fs10.closeSync(fd);
2405
2647
  }
2406
2648
  }
2407
2649
  function gatherContext(taskId, deps) {
@@ -2672,80 +2914,34 @@ function watchCmd(args2, deps = {}) {
2672
2914
  }
2673
2915
 
2674
2916
  // src/task/index.ts
2675
- import { spawnSync as spawnSync7 } from "child_process";
2676
- import fs14 from "fs";
2677
- import path14 from "path";
2678
-
2679
- // src/orchestrator/canon-snapshot.ts
2680
- import { spawnSync as spawnSync6 } from "child_process";
2681
- import fs10 from "fs";
2682
- import path10 from "path";
2683
-
2684
- // src/orchestrator/git.ts
2685
- import { spawnSync as spawnSync5 } from "child_process";
2686
- import fs9 from "fs";
2687
- import path9 from "path";
2688
-
2689
- // src/orchestrator/worktree.ts
2690
- import fs8 from "fs";
2691
- import path8 from "path";
2692
- var PIPELINE_TELEMETRY_FILES = [
2693
- "docs/pipeline-invocations.md",
2694
- "docs/task-quality-log.md",
2695
- "docs/lessons-learned.md"
2696
- ];
2697
- var PIPELINE_MANAGED_DOCS = [
2698
- "docs/architecture.md",
2699
- "docs/codebase-map.md",
2700
- "docs/decisions.md",
2701
- "docs/patterns.md",
2702
- "docs/pipeline-orchestrator.md",
2703
- "docs/product-context.md"
2704
- ];
2705
- var PIPELINE_SHARED_DOCS = [...PIPELINE_TELEMETRY_FILES, ...PIPELINE_MANAGED_DOCS];
2706
-
2707
- // src/orchestrator/git.ts
2708
- function gitSafeAt(cwd, ...args2) {
2709
- const result = spawnSync5("git", args2, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2710
- if (result.error) return { ok: false, stdout: "", stderr: result.error.message };
2711
- return { ok: result.status === 0, stdout: (result.stdout ?? "").trim(), stderr: (result.stderr ?? "").trim() };
2712
- }
2713
- function filterGitIgnoredPaths(paths, cwd) {
2714
- if (paths.length === 0) return /* @__PURE__ */ new Set();
2715
- const result = spawnSync5("git", ["check-ignore", "--stdin", "-z"], {
2716
- cwd,
2717
- input: `${paths.join("\0")}\0`,
2718
- encoding: "utf8",
2719
- stdio: ["pipe", "pipe", "pipe"]
2720
- });
2721
- if (result.error || result.status !== 0 && result.status !== 1) {
2722
- return /* @__PURE__ */ new Set();
2723
- }
2724
- const stdout = result.stdout ?? "";
2725
- return new Set(stdout.split("\0").filter((p) => p.length > 0));
2726
- }
2917
+ import { spawnSync as spawnSync8 } from "child_process";
2918
+ import fs15 from "fs";
2919
+ import path15 from "path";
2727
2920
 
2728
2921
  // src/orchestrator/canon-snapshot.ts
2922
+ import { spawnSync as spawnSync7 } from "child_process";
2923
+ import fs11 from "fs";
2924
+ import path11 from "path";
2729
2925
  var CANON_UPSTREAM_REPO = "tstraub89/canon-ai";
2730
2926
  function isInstalledSourcePath(sourcePath) {
2731
2927
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
2732
2928
  }
2733
2929
  function resolveCanonVersion(explicit) {
2734
- return explicit ?? "3.7.0" ?? "dev";
2930
+ return explicit ?? "3.9.0" ?? "dev";
2735
2931
  }
2736
2932
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
2737
2933
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
2738
2934
  if (!ownToplevel) return upstreamCommit;
2739
- const parentDir = path10.dirname(repoRoot);
2935
+ const parentDir = path11.dirname(repoRoot);
2740
2936
  const parentToplevel = captureGitOutput(parentDir, ["rev-parse", "--show-toplevel"], runGitAt);
2741
2937
  if (!parentToplevel) return upstreamCommit;
2742
- if (path10.resolve(parentToplevel) === path10.resolve(ownToplevel)) {
2938
+ if (path11.resolve(parentToplevel) === path11.resolve(ownToplevel)) {
2743
2939
  return upstreamCommit;
2744
2940
  }
2745
- return captureGitOutput(path10.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
2941
+ return captureGitOutput(path11.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
2746
2942
  }
2747
2943
  function defaultRunCommand(command2, args2) {
2748
- const result = spawnSync6(command2, args2, {
2944
+ const result = spawnSync7(command2, args2, {
2749
2945
  cwd: REPO_ROOT,
2750
2946
  encoding: "utf8",
2751
2947
  stdio: ["ignore", "pipe", "pipe"]
@@ -2776,7 +2972,7 @@ function captureCanonSnapshot(repoRoot = REPO_ROOT, options = {}) {
2776
2972
  const isInstalled = isInstalledSourcePath(canonSourcePath);
2777
2973
  const superprojectWorkingTree = captureGitOutput(repoRoot, ["rev-parse", "--show-superproject-working-tree"], runGitAt);
2778
2974
  const drivingCommit = captureGitOutput(repoRoot, ["rev-parse", "HEAD"], runGitAt) || "<unavailable>";
2779
- const hostCommit = superprojectWorkingTree ? captureGitOutput(path10.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
2975
+ const hostCommit = superprojectWorkingTree ? captureGitOutput(path11.resolve(superprojectWorkingTree), ["rev-parse", "HEAD"], runGitAt) || "<unavailable>" : null;
2780
2976
  let upstreamCommit;
2781
2977
  let orchestratorCommit;
2782
2978
  if (isInstalled) {
@@ -2810,47 +3006,47 @@ function applyCanonSnapshot(status, canon) {
2810
3006
  return next;
2811
3007
  }
2812
3008
  function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
2813
- const status = JSON.parse(fs10.readFileSync(statusFilePath, "utf8"));
3009
+ const status = JSON.parse(fs11.readFileSync(statusFilePath, "utf8"));
2814
3010
  const canon = captureCanonSnapshot(REPO_ROOT, options);
2815
3011
  const next = applyCanonSnapshot(status, canon);
2816
3012
  const serialized = `${JSON.stringify(next, null, 2)}
2817
3013
  `;
2818
- const current = fs10.readFileSync(statusFilePath, "utf8");
3014
+ const current = fs11.readFileSync(statusFilePath, "utf8");
2819
3015
  if (current !== serialized) {
2820
- fs10.writeFileSync(statusFilePath, serialized, "utf8");
3016
+ fs11.writeFileSync(statusFilePath, serialized, "utf8");
2821
3017
  }
2822
3018
  return canon;
2823
3019
  }
2824
3020
 
2825
3021
  // src/orchestrator/review-archive.ts
2826
- import fs13 from "fs";
2827
- import path13 from "path";
3022
+ import fs14 from "fs";
3023
+ import path14 from "path";
2828
3024
 
2829
3025
  // src/task/templates.ts
2830
- import fs11 from "fs";
2831
- import path11 from "path";
3026
+ import fs12 from "fs";
3027
+ import path12 from "path";
2832
3028
  function tasksRoot() {
2833
3029
  return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
2834
3030
  }
2835
3031
  function templatesRoot() {
2836
- return path11.join(process.cwd(), ".canon", "templates");
3032
+ return path12.join(process.cwd(), ".canon", "templates");
2837
3033
  }
2838
3034
  function taskTemplateOverrideRoot() {
2839
- return path11.join(tasksRoot(), "_templates");
3035
+ return path12.join(tasksRoot(), "_templates");
2840
3036
  }
2841
3037
  function resolveTaskTemplateSource(basename2) {
2842
- const override = path11.join(taskTemplateOverrideRoot(), basename2);
2843
- if (fs11.existsSync(override)) return override;
2844
- const managed = path11.join(templatesRoot(), basename2);
2845
- return fs11.existsSync(managed) ? managed : null;
3038
+ const override = path12.join(taskTemplateOverrideRoot(), basename2);
3039
+ if (fs12.existsSync(override)) return override;
3040
+ const managed = path12.join(templatesRoot(), basename2);
3041
+ return fs12.existsSync(managed) ? managed : null;
2846
3042
  }
2847
3043
  function renderTaskTemplate(source, taskId, title) {
2848
- return fs11.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
3044
+ return fs12.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
2849
3045
  }
2850
3046
  function scaffoldTaskArtifact(taskDir, basename2, taskId, title) {
2851
3047
  const source = resolveTaskTemplateSource(basename2);
2852
3048
  if (!source) return null;
2853
- fs11.writeFileSync(path11.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
3049
+ fs12.writeFileSync(path12.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
2854
3050
  return source;
2855
3051
  }
2856
3052
  function isPristineTaskArtifact(content, basename2, taskId, title) {
@@ -2865,8 +3061,8 @@ function isPristineTaskArtifact(content, basename2, taskId, title) {
2865
3061
  }
2866
3062
 
2867
3063
  // src/orchestrator/validation.ts
2868
- import fs12 from "fs";
2869
- import path12 from "path";
3064
+ import fs13 from "fs";
3065
+ import path13 from "path";
2870
3066
  function computeLatestValidationResults(handoffContent) {
2871
3067
  const latest = /* @__PURE__ */ new Map();
2872
3068
  const baseline = parseTable(handoffContent, "Validation Outcomes");
@@ -2997,7 +3193,7 @@ function isTemplateUnfilled(content) {
2997
3193
  function isDoneMdTemplate(donePath) {
2998
3194
  let content;
2999
3195
  try {
3000
- content = fs12.readFileSync(donePath, "utf8");
3196
+ content = fs13.readFileSync(donePath, "utf8");
3001
3197
  } catch {
3002
3198
  return true;
3003
3199
  }
@@ -3027,16 +3223,16 @@ var PHASE_GATE_CONFIG = {
3027
3223
  human_review: {}
3028
3224
  };
3029
3225
  function resolveTaskDirForValidation(taskId, taskDirOverride) {
3030
- return taskDirOverride ? path12.join(taskDirOverride, taskId) : taskDirFor(taskId);
3226
+ return taskDirOverride ? path13.join(taskDirOverride, taskId) : taskDirFor(taskId);
3031
3227
  }
3032
3228
  function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3033
3229
  const config2 = PHASE_GATE_CONFIG[phase];
3034
3230
  const taskDir = resolveTaskDirForValidation(taskId, taskDirOverride);
3035
3231
  if (config2.artifactName) {
3036
- const artifactPath = path12.join(taskDir, config2.artifactName);
3232
+ const artifactPath = path13.join(taskDir, config2.artifactName);
3037
3233
  let content;
3038
3234
  try {
3039
- content = fs12.readFileSync(artifactPath, "utf8");
3235
+ content = fs13.readFileSync(artifactPath, "utf8");
3040
3236
  } catch {
3041
3237
  return { ok: false, reason: `${config2.artifactName} is missing for phase '${phase}'` };
3042
3238
  }
@@ -3048,7 +3244,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3048
3244
  if (phase === "spec_review" || phase === "plan") {
3049
3245
  let statusRaw;
3050
3246
  try {
3051
- statusRaw = fs12.readFileSync(path12.join(taskDir, "status.json"), "utf8");
3247
+ statusRaw = fs13.readFileSync(path13.join(taskDir, "status.json"), "utf8");
3052
3248
  } catch {
3053
3249
  return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
3054
3250
  }
@@ -3083,19 +3279,19 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
3083
3279
  }
3084
3280
  }
3085
3281
  if (phase === "human_review") {
3086
- const handoffPath = path12.join(taskDir, "handoff.md");
3282
+ const handoffPath = path13.join(taskDir, "handoff.md");
3087
3283
  let handoffContent;
3088
3284
  try {
3089
- handoffContent = fs12.readFileSync(handoffPath, "utf8");
3285
+ handoffContent = fs13.readFileSync(handoffPath, "utf8");
3090
3286
  } catch {
3091
3287
  return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
3092
3288
  }
3093
3289
  const pending = countHumanPendingChecks(handoffContent);
3094
3290
  if (pending.length === 0) return { ok: true };
3095
- const donePath = path12.join(taskDir, "done.md");
3291
+ const donePath = path13.join(taskDir, "done.md");
3096
3292
  let doneContent = "";
3097
3293
  try {
3098
- doneContent = fs12.readFileSync(donePath, "utf8");
3294
+ doneContent = fs13.readFileSync(donePath, "utf8");
3099
3295
  } catch {
3100
3296
  }
3101
3297
  if (hasHumanPendingWaiver(doneContent)) return { ok: true };
@@ -3110,10 +3306,10 @@ ${list}
3110
3306
  return { ok: true };
3111
3307
  }
3112
3308
  function parseHandoffChangesRows(taskId) {
3113
- const handoffPath = path12.join(taskDirFor(taskId), "handoff.md");
3309
+ const handoffPath = path13.join(taskDirFor(taskId), "handoff.md");
3114
3310
  let content;
3115
3311
  try {
3116
- content = fs12.readFileSync(handoffPath, "utf8");
3312
+ content = fs13.readFileSync(handoffPath, "utf8");
3117
3313
  } catch {
3118
3314
  return { files: [], malformed: [] };
3119
3315
  }
@@ -3417,29 +3613,29 @@ var REVIEW_ARCHIVE_PREFIX = "review-prior-";
3417
3613
  var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
3418
3614
  function newestReviewArchiveNumber(taskDir) {
3419
3615
  let newest = 0;
3420
- for (const name of fs13.readdirSync(taskDir)) {
3616
+ for (const name of fs14.readdirSync(taskDir)) {
3421
3617
  const match = REVIEW_ARCHIVE_RE.exec(name);
3422
3618
  if (match) newest = Math.max(newest, Number(match[1]));
3423
3619
  }
3424
3620
  return newest;
3425
3621
  }
3426
3622
  function archivePriorReview(taskDir, options = {}) {
3427
- const reviewPath = path13.join(taskDir, "review.md");
3428
- if (!fs13.existsSync(reviewPath)) return null;
3623
+ const reviewPath = path14.join(taskDir, "review.md");
3624
+ if (!fs14.existsSync(reviewPath)) return null;
3429
3625
  if (options.skipUnfilledTemplate) {
3430
- const content = fs13.readFileSync(reviewPath, "utf8");
3626
+ const content = fs14.readFileSync(reviewPath, "utf8");
3431
3627
  if (isTemplateUnfilled(content)) return null;
3432
3628
  if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
3433
3629
  return null;
3434
3630
  }
3435
3631
  }
3436
3632
  const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
3437
- fs13.renameSync(reviewPath, path13.join(taskDir, archiveName));
3633
+ fs14.renameSync(reviewPath, path14.join(taskDir, archiveName));
3438
3634
  return archiveName;
3439
3635
  }
3440
3636
  function rescaffoldReview(taskDir, identity) {
3441
3637
  try {
3442
- if (fs13.existsSync(path13.join(taskDir, "review.md"))) return { outcome: "exists" };
3638
+ if (fs14.existsSync(path14.join(taskDir, "review.md"))) return { outcome: "exists" };
3443
3639
  const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
3444
3640
  return source ? { outcome: "written", source } : { outcome: "no-template" };
3445
3641
  } catch (error) {
@@ -3498,25 +3694,25 @@ function validateTaskId(id) {
3498
3694
  }
3499
3695
  }
3500
3696
  function taskDirFromRoot(taskId) {
3501
- return path14.join(tasksRoot(), taskId);
3697
+ return path15.join(tasksRoot(), taskId);
3502
3698
  }
3503
3699
  function taskDirForCwd(_cwd, taskId) {
3504
3700
  const root = tasksRoot();
3505
- if (path14.isAbsolute(root)) {
3506
- return path14.join(root, taskId);
3701
+ if (path15.isAbsolute(root)) {
3702
+ return path15.join(root, taskId);
3507
3703
  }
3508
- return path14.join(resolveTaskCwd(taskId), root, taskId);
3704
+ return path15.join(resolveTaskCwd(taskId), root, taskId);
3509
3705
  }
3510
3706
  function taskStatusFileForCwd(cwd, taskId) {
3511
- return path14.join(taskDirForCwd(cwd, taskId), "status.json");
3707
+ return path15.join(taskDirForCwd(cwd, taskId), "status.json");
3512
3708
  }
3513
3709
  function taskRootForGate(cwd) {
3514
3710
  const root = tasksRoot();
3515
- return path14.isAbsolute(root) ? root : path14.join(cwd, root);
3711
+ return path15.isAbsolute(root) ? root : path15.join(cwd, root);
3516
3712
  }
3517
3713
  function readJsonFile(filePath) {
3518
3714
  try {
3519
- return JSON.parse(fs14.readFileSync(filePath, "utf8"));
3715
+ return JSON.parse(fs15.readFileSync(filePath, "utf8"));
3520
3716
  } catch (error) {
3521
3717
  const message = error instanceof Error ? error.message : String(error);
3522
3718
  throw new Error(`Error: failed to read ${filePath}: ${message}`);
@@ -3524,9 +3720,9 @@ function readJsonFile(filePath) {
3524
3720
  }
3525
3721
  function writeJsonAtomic(filePath, data) {
3526
3722
  const tmpFile = `${filePath}.tmp`;
3527
- fs14.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3723
+ fs15.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
3528
3724
  `, "utf8");
3529
- fs14.renameSync(tmpFile, filePath);
3725
+ fs15.renameSync(tmpFile, filePath);
3530
3726
  }
3531
3727
  function writeStatusAtomic(filePath, status) {
3532
3728
  status.status = deriveTopLevelStatus(status);
@@ -3534,13 +3730,13 @@ function writeStatusAtomic(filePath, status) {
3534
3730
  }
3535
3731
  function runGit(args2, options = {}) {
3536
3732
  if (options.stdio === "inherit") {
3537
- return spawnSync7("git", args2, {
3733
+ return spawnSync8("git", args2, {
3538
3734
  cwd: options.cwd ?? process.cwd(),
3539
3735
  encoding: "utf8",
3540
3736
  stdio: "inherit"
3541
3737
  });
3542
3738
  }
3543
- return spawnSync7("git", args2, {
3739
+ return spawnSync8("git", args2, {
3544
3740
  cwd: options.cwd ?? process.cwd(),
3545
3741
  encoding: "utf8",
3546
3742
  stdio: ["ignore", "pipe", "pipe"]
@@ -3567,15 +3763,15 @@ function currentBranchOrEmpty() {
3567
3763
  }
3568
3764
  function listTemplateFiles() {
3569
3765
  const root = templatesRoot();
3570
- if (!fs14.existsSync(root)) {
3766
+ if (!fs15.existsSync(root)) {
3571
3767
  throw new Error(`Error: templates directory not found at ${root}`);
3572
3768
  }
3573
- return fs14.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3769
+ return fs15.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
3574
3770
  }
3575
3771
  function printCreatedTask(taskId, taskDir, baseBranch) {
3576
3772
  console.log(`Created task: ${taskDir}`);
3577
3773
  console.log("Files:");
3578
- for (const file of fs14.readdirSync(taskDir).sort()) {
3774
+ for (const file of fs15.readdirSync(taskDir).sort()) {
3579
3775
  console.log(file);
3580
3776
  }
3581
3777
  console.log("");
@@ -3613,18 +3809,18 @@ function taskNew(args2) {
3613
3809
  throw new Error("Error: title must be single-line (no embedded newlines).");
3614
3810
  }
3615
3811
  const taskDir = taskDirFromRoot(id);
3616
- if (fs14.existsSync(taskDir)) {
3812
+ if (fs15.existsSync(taskDir)) {
3617
3813
  throw new Error(`Error: Task directory ${taskDir} already exists.`);
3618
3814
  }
3619
3815
  if (!baseBranch) {
3620
3816
  baseBranch = currentBranchOrEmpty() || "main";
3621
3817
  }
3622
- fs14.mkdirSync(taskDir, { recursive: true });
3818
+ fs15.mkdirSync(taskDir, { recursive: true });
3623
3819
  for (const basename2 of listTemplateFiles()) {
3624
- const source = resolveTaskTemplateSource(basename2) ?? path14.join(templatesRoot(), basename2);
3625
- fs14.writeFileSync(path14.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
3820
+ const source = resolveTaskTemplateSource(basename2) ?? path15.join(templatesRoot(), basename2);
3821
+ fs15.writeFileSync(path15.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
3626
3822
  }
3627
- const statusPath = path14.join(taskDir, "status.json");
3823
+ const statusPath = path15.join(taskDir, "status.json");
3628
3824
  const status = readJsonFile(statusPath);
3629
3825
  status.id = id;
3630
3826
  status.title = title;
@@ -3645,31 +3841,31 @@ function derivePhase(status) {
3645
3841
  }
3646
3842
  function taskList() {
3647
3843
  const root = tasksRoot();
3648
- if (!fs14.existsSync(root)) {
3844
+ if (!fs15.existsSync(root)) {
3649
3845
  console.log("No tasks found.");
3650
3846
  return;
3651
3847
  }
3652
3848
  const rows = [];
3653
3849
  let invalidCount = 0;
3654
- for (const entry of fs14.readdirSync(root).sort()) {
3850
+ for (const entry of fs15.readdirSync(root).sort()) {
3655
3851
  if (entry === "_archive" || entry === "_templates") continue;
3656
3852
  if (isOrphanedWorktreeState(entry)) {
3657
3853
  invalidCount += 1;
3658
3854
  let title = "(untitled)";
3659
3855
  try {
3660
- const frozen = readJsonFile(path14.join(taskDirForRepoRoot(entry), "status.json"));
3856
+ const frozen = readJsonFile(path15.join(taskDirForRepoRoot(entry), "status.json"));
3661
3857
  title = frozen.title ?? title;
3662
3858
  } catch {
3663
3859
  }
3664
3860
  rows.push({
3665
3861
  id: entry,
3666
3862
  title,
3667
- phase: `INVALID: worktree missing \u2014 restore ${path14.join(effectiveWorktreesRoot(), entry)} or archive the task`
3863
+ phase: `INVALID: worktree missing \u2014 restore ${path15.join(effectiveWorktreesRoot(), entry)} or archive the task`
3668
3864
  });
3669
3865
  continue;
3670
3866
  }
3671
- const statusPath = path14.join(taskDirForCwd(process.cwd(), entry), "status.json");
3672
- if (!fs14.existsSync(statusPath)) continue;
3867
+ const statusPath = path15.join(taskDirForCwd(process.cwd(), entry), "status.json");
3868
+ if (!fs15.existsSync(statusPath)) continue;
3673
3869
  try {
3674
3870
  const status = readJsonFile(statusPath);
3675
3871
  const phase = derivePhase(status);
@@ -3706,7 +3902,7 @@ function taskStatus(id) {
3706
3902
  validateTaskId(id);
3707
3903
  const cwd = resolveTaskCwd(id);
3708
3904
  const statusPath = taskStatusFileForCwd(cwd, id);
3709
- if (!fs14.existsSync(statusPath)) {
3905
+ if (!fs15.existsSync(statusPath)) {
3710
3906
  throw new Error(`Error: No status.json found for task ${id}`);
3711
3907
  }
3712
3908
  const status = readJsonFile(statusPath);
@@ -3779,7 +3975,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
3779
3975
  assertValidVerdict(phaseArg, verdictArg);
3780
3976
  const taskCwd = resolveTaskCwd(id);
3781
3977
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3782
- if (!fs14.existsSync(statusPath)) {
3978
+ if (!fs15.existsSync(statusPath)) {
3783
3979
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3784
3980
  }
3785
3981
  const status = readJsonFile(statusPath);
@@ -3821,7 +4017,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
3821
4017
  writeQualityLogForTask(
3822
4018
  id,
3823
4019
  taskCwd,
3824
- path14.join(taskDirForCwd(taskCwd, id), "done.md"),
4020
+ path15.join(taskDirForCwd(taskCwd, id), "done.md"),
3825
4021
  status
3826
4022
  );
3827
4023
  }
@@ -3845,7 +4041,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3845
4041
  for (const id of ids) {
3846
4042
  const taskCwd = resolveTaskCwd(id);
3847
4043
  const statusPath = taskStatusFileForCwd(taskCwd, id);
3848
- if (!fs14.existsSync(statusPath)) {
4044
+ if (!fs15.existsSync(statusPath)) {
3849
4045
  throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
3850
4046
  }
3851
4047
  const status = readJsonFile(statusPath);
@@ -3947,7 +4143,7 @@ function taskAccept(ids, phaseArg, options = {}) {
3947
4143
  const originalSnapshots2 = /* @__PURE__ */ new Map();
3948
4144
  for (const ctx of ctxByTask.values()) {
3949
4145
  try {
3950
- originalSnapshots2.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
4146
+ originalSnapshots2.set(ctx.statusPath, fs15.readFileSync(ctx.statusPath, "utf8"));
3951
4147
  } catch (error) {
3952
4148
  const message = error instanceof Error ? error.message : String(error);
3953
4149
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -3984,8 +4180,8 @@ function taskAccept(ids, phaseArg, options = {}) {
3984
4180
  if (original === void 0) continue;
3985
4181
  try {
3986
4182
  const tmpFile = `${filePath}.rollback.tmp`;
3987
- fs14.writeFileSync(tmpFile, original, "utf8");
3988
- fs14.renameSync(tmpFile, filePath);
4183
+ fs15.writeFileSync(tmpFile, original, "utf8");
4184
+ fs15.renameSync(tmpFile, filePath);
3989
4185
  } catch (rollbackErr) {
3990
4186
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
3991
4187
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -4003,18 +4199,18 @@ ${rollbackErrors.join("\n")}`
4003
4199
  throw new Error(`Error: bundled review accept failed; rolled back to pre-accept state. Original error: ${originalMessage}`);
4004
4200
  }
4005
4201
  for (const ctx of ctxByTask.values()) {
4006
- const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4202
+ const notesPath = path15.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4007
4203
  const entry = ctx.status.phases[phaseArg];
4008
4204
  const sanctioned = entry?.verdict === "sanctioned";
4009
4205
  const bundleNote = ids.length > 1 ? ` Bundle: ${ids.join(", ")}.` : "";
4010
4206
  const noteLine = `[${today()}] Operator accepted ${phaseArg} via \`canon task accept\` \u2014 ${sanctioned ? "sanctioned (agent verdict overridden)" : "unblocked (advancing verdict preserved)"}. Reason: ${reason}.${bundleNote}`;
4011
4207
  try {
4012
- if (fs14.existsSync(notesPath)) {
4013
- fs14.appendFileSync(notesPath, `
4208
+ if (fs15.existsSync(notesPath)) {
4209
+ fs15.appendFileSync(notesPath, `
4014
4210
  ${noteLine}
4015
4211
  `, "utf8");
4016
4212
  } else {
4017
- fs14.writeFileSync(notesPath, `${noteLine}
4213
+ fs15.writeFileSync(notesPath, `${noteLine}
4018
4214
  `, "utf8");
4019
4215
  }
4020
4216
  } catch (error) {
@@ -4137,7 +4333,7 @@ ${noteLine}
4137
4333
  const originalSnapshots = /* @__PURE__ */ new Map();
4138
4334
  for (const ctx of ctxByTask.values()) {
4139
4335
  try {
4140
- originalSnapshots.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
4336
+ originalSnapshots.set(ctx.statusPath, fs15.readFileSync(ctx.statusPath, "utf8"));
4141
4337
  } catch (error) {
4142
4338
  const message = error instanceof Error ? error.message : String(error);
4143
4339
  throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
@@ -4162,8 +4358,8 @@ ${noteLine}
4162
4358
  if (original === void 0) continue;
4163
4359
  try {
4164
4360
  const tmpFile = `${filePath}.rollback.tmp`;
4165
- fs14.writeFileSync(tmpFile, original, "utf8");
4166
- fs14.renameSync(tmpFile, filePath);
4361
+ fs15.writeFileSync(tmpFile, original, "utf8");
4362
+ fs15.renameSync(tmpFile, filePath);
4167
4363
  } catch (rollbackErr) {
4168
4364
  const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
4169
4365
  rollbackErrors.push(` ${filePath}: ${message}`);
@@ -4182,15 +4378,15 @@ ${noteLine}
4182
4378
  throw new Error(`Error: bundled accept failed; rolled back to pre-accept state. Original error: ${originalMessage}`);
4183
4379
  }
4184
4380
  for (const ctx of ctxByTask.values()) {
4185
- const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4381
+ const notesPath = path15.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
4186
4382
  const noteLine = `[${today()}] Operator accepted implement phase via \`canon task accept\` \u2014 auto-commit will be skipped.${options.force ? " (--force)" : ""}`;
4187
4383
  try {
4188
- if (fs14.existsSync(notesPath)) {
4189
- fs14.appendFileSync(notesPath, `
4384
+ if (fs15.existsSync(notesPath)) {
4385
+ fs15.appendFileSync(notesPath, `
4190
4386
  ${noteLine}
4191
4387
  `, "utf8");
4192
4388
  } else {
4193
- fs14.writeFileSync(notesPath, `${noteLine}
4389
+ fs15.writeFileSync(notesPath, `${noteLine}
4194
4390
  `, "utf8");
4195
4391
  }
4196
4392
  } catch (error) {
@@ -4209,15 +4405,15 @@ function taskResetSpecReview(id) {
4209
4405
  validateTaskId(id);
4210
4406
  const taskCwd = resolveTaskCwd(id);
4211
4407
  const taskDir = taskDirForCwd(taskCwd, id);
4212
- const statusPath = path14.join(taskDir, "status.json");
4213
- if (!fs14.existsSync(statusPath)) {
4408
+ const statusPath = path15.join(taskDir, "status.json");
4409
+ if (!fs15.existsSync(statusPath)) {
4214
4410
  throw new Error(`Error: no status.json at ${statusPath}`);
4215
4411
  }
4216
- const reviewPath = path14.join(taskDir, "spec-review.md");
4217
- if (fs14.existsSync(reviewPath)) {
4412
+ const reviewPath = path15.join(taskDir, "spec-review.md");
4413
+ if (fs15.existsSync(reviewPath)) {
4218
4414
  let n = 1;
4219
- while (fs14.existsSync(path14.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4220
- fs14.renameSync(reviewPath, path14.join(taskDir, `spec-review-prior-${n}.md`));
4415
+ while (fs15.existsSync(path15.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
4416
+ fs15.renameSync(reviewPath, path15.join(taskDir, `spec-review-prior-${n}.md`));
4221
4417
  console.log(`Archived prior spec-review.md \u2192 spec-review-prior-${n}.md`);
4222
4418
  }
4223
4419
  const status = readJsonFile(statusPath);
@@ -4240,8 +4436,8 @@ function taskResetCodeReview(id) {
4240
4436
  validateTaskId(id);
4241
4437
  const taskCwd = resolveTaskCwd(id);
4242
4438
  const taskDir = taskDirForCwd(taskCwd, id);
4243
- const statusPath = path14.join(taskDir, "status.json");
4244
- if (!fs14.existsSync(statusPath)) {
4439
+ const statusPath = path15.join(taskDir, "status.json");
4440
+ if (!fs15.existsSync(statusPath)) {
4245
4441
  throw new Error(`Error: no status.json at ${statusPath}`);
4246
4442
  }
4247
4443
  const status = readJsonFile(statusPath);
@@ -4256,7 +4452,7 @@ function taskResetCodeReview(id) {
4256
4452
  }
4257
4453
  const rescaffold = rescaffoldReview(taskDir, { taskId: id, title: status.title ?? "" });
4258
4454
  if (rescaffold.outcome === "written") {
4259
- console.log(`Re-scaffolded review.md from ${path14.relative(process.cwd(), rescaffold.source)}`);
4455
+ console.log(`Re-scaffolded review.md from ${path15.relative(process.cwd(), rescaffold.source)}`);
4260
4456
  } else if (rescaffold.outcome === "no-template") {
4261
4457
  console.warn(
4262
4458
  "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."
@@ -4284,7 +4480,7 @@ function taskResetCodeReview(id) {
4284
4480
  );
4285
4481
  }
4286
4482
  function ensureGitAvailable() {
4287
- const result = spawnSync7("git", ["--version"], { stdio: "ignore" });
4483
+ const result = spawnSync8("git", ["--version"], { stdio: "ignore" });
4288
4484
  if (result.error || result.status !== 0) {
4289
4485
  throw new Error("Error: git is required.");
4290
4486
  }
@@ -4299,16 +4495,16 @@ function parsePorcelainPath(line) {
4299
4495
  }
4300
4496
  function isPipelineOwnedAcceptPath(filePath, taskId, gitCwd) {
4301
4497
  const repoRootForPaths = resolveRepoRootForAccept(gitCwd);
4302
- const dirtyAbsolute = path14.isAbsolute(filePath) ? filePath : path14.resolve(repoRootForPaths, filePath);
4498
+ const dirtyAbsolute = path15.isAbsolute(filePath) ? filePath : path15.resolve(repoRootForPaths, filePath);
4303
4499
  const canonicalDirty = safeRealpath(dirtyAbsolute);
4304
4500
  const root = tasksRoot();
4305
- const rootAbsolute = path14.isAbsolute(root) ? root : path14.resolve(repoRootForPaths, root);
4501
+ const rootAbsolute = path15.isAbsolute(root) ? root : path15.resolve(repoRootForPaths, root);
4306
4502
  const canonicalRoot = safeRealpath(rootAbsolute);
4307
- const taskCanonical = path14.join(canonicalRoot, taskId);
4503
+ const taskCanonical = path15.join(canonicalRoot, taskId);
4308
4504
  if (canonicalDirty === taskCanonical) return true;
4309
- if (canonicalDirty.startsWith(`${taskCanonical}${path14.sep}`)) return true;
4505
+ if (canonicalDirty.startsWith(`${taskCanonical}${path15.sep}`)) return true;
4310
4506
  for (const telemetry of PIPELINE_TELEMETRY_FILES) {
4311
- const telemetryAbsolute = path14.resolve(repoRootForPaths, telemetry);
4507
+ const telemetryAbsolute = path15.resolve(repoRootForPaths, telemetry);
4312
4508
  if (safeRealpath(telemetryAbsolute) === canonicalDirty) return true;
4313
4509
  }
4314
4510
  return false;
@@ -4323,16 +4519,16 @@ function resolveMainCheckoutRoot() {
4323
4519
  if (out.error || out.status !== 0) return process.cwd();
4324
4520
  const gitCommonDir = (out.stdout ?? "").trim();
4325
4521
  if (!gitCommonDir) return process.cwd();
4326
- return path14.dirname(gitCommonDir);
4522
+ return path15.dirname(gitCommonDir);
4327
4523
  }
4328
4524
  function safeRealpath(target) {
4329
4525
  try {
4330
- return fs14.realpathSync(target);
4526
+ return fs15.realpathSync(target);
4331
4527
  } catch {
4332
- const parent = path14.dirname(target);
4528
+ const parent = path15.dirname(target);
4333
4529
  if (parent === target) return target;
4334
4530
  try {
4335
- return path14.join(fs14.realpathSync(parent), path14.basename(target));
4531
+ return path15.join(fs15.realpathSync(parent), path15.basename(target));
4336
4532
  } catch {
4337
4533
  return target;
4338
4534
  }
@@ -4440,12 +4636,12 @@ function taskPostMergeSync(branchArg) {
4440
4636
  }
4441
4637
  function nudgeShippableTasks() {
4442
4638
  const root = tasksRoot();
4443
- if (!fs14.existsSync(root)) return;
4639
+ if (!fs15.existsSync(root)) return;
4444
4640
  const shippable = [];
4445
- for (const entry of fs14.readdirSync(root).sort()) {
4641
+ for (const entry of fs15.readdirSync(root).sort()) {
4446
4642
  if (entry === "_archive" || entry.startsWith("_")) continue;
4447
- const statusPath = path14.join(root, entry, "status.json");
4448
- if (!fs14.existsSync(statusPath)) continue;
4643
+ const statusPath = path15.join(root, entry, "status.json");
4644
+ if (!fs15.existsSync(statusPath)) continue;
4449
4645
  let status;
4450
4646
  try {
4451
4647
  status = readJsonFile(statusPath);
@@ -4549,7 +4745,7 @@ function taskSet(args2) {
4549
4745
  validateTaskId(id);
4550
4746
  const taskCwd = resolveTaskCwd(id);
4551
4747
  const statusPath = taskStatusFileForCwd(taskCwd, id);
4552
- if (!fs14.existsSync(statusPath)) {
4748
+ if (!fs15.existsSync(statusPath)) {
4553
4749
  throw new Error(`Error: No status.json found for task ${id}`);
4554
4750
  }
4555
4751
  const status = readJsonFile(statusPath);
@@ -4665,7 +4861,7 @@ function taskCmd2(args2) {
4665
4861
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, realpathSync as realpathSync2, writeFileSync as writeFileSync2 } from "fs";
4666
4862
  import { fileURLToPath as fileURLToPath4 } from "url";
4667
4863
  import { dirname as dirname4, join as join4 } from "path";
4668
- import { spawnSync as spawnSync8 } from "child_process";
4864
+ import { spawnSync as spawnSync9 } from "child_process";
4669
4865
  var packageDir3 = join4(dirname4(fileURLToPath4(import.meta.url)), "../..");
4670
4866
  function detectInstallType(pkgDirOverride) {
4671
4867
  const dir = pkgDirOverride ?? packageDir3;
@@ -4718,7 +4914,7 @@ function nonInteractiveSshCommand(configuredCommand) {
4718
4914
  return `${command2} -oBatchMode=yes`;
4719
4915
  }
4720
4916
  function defaultGitRunner(args2) {
4721
- const result = spawnSync8("git", args2, {
4917
+ const result = spawnSync9("git", args2, {
4722
4918
  encoding: "utf8",
4723
4919
  timeout: GIT_RESOLUTION_TIMEOUT_MS,
4724
4920
  env: {
@@ -4893,7 +5089,7 @@ function currentPinFromManifest(manifest) {
4893
5089
  return "unknown";
4894
5090
  }
4895
5091
  function bakedVersion() {
4896
- return "3.7.0";
5092
+ return "3.9.0";
4897
5093
  }
4898
5094
  function ownPackageName(pkgDir) {
4899
5095
  try {
@@ -4905,7 +5101,7 @@ function ownPackageName(pkgDir) {
4905
5101
  }
4906
5102
  var NPM_VIEW_TIMEOUT_MS = 3e4;
4907
5103
  function defaultNpmViewRunner(args2, cwd) {
4908
- const result = spawnSync8("npm", args2, { encoding: "utf8", timeout: NPM_VIEW_TIMEOUT_MS, ...cwd ? { cwd } : {} });
5104
+ const result = spawnSync9("npm", args2, { encoding: "utf8", timeout: NPM_VIEW_TIMEOUT_MS, ...cwd ? { cwd } : {} });
4909
5105
  if (result.error) return { status: null, stdout: "", stderr: result.error.message };
4910
5106
  return { status: result.status, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
4911
5107
  }
@@ -4961,7 +5157,7 @@ function updateCmd(args2, deps = {}) {
4961
5157
  const pkgDir = deps.packageDir ?? packageDir3;
4962
5158
  const pkgName = ownPackageName(pkgDir);
4963
5159
  const cwd = deps.cwd ?? process.cwd();
4964
- const spawn2 = deps.spawnRunner ?? ((cmd, cmdArgs, opts) => spawnSync8(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }));
5160
+ const spawn2 = deps.spawnRunner ?? ((cmd, cmdArgs, opts) => spawnSync9(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }));
4965
5161
  const runGit2 = deps.gitRunner ?? defaultGitRunner;
4966
5162
  const nowIso = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
4967
5163
  let options;
@@ -5092,7 +5288,7 @@ function updateCmd(args2, deps = {}) {
5092
5288
  import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3 } from "fs";
5093
5289
  import { fileURLToPath as fileURLToPath5 } from "url";
5094
5290
  import { basename, dirname as dirname5, join as join5, relative as relative3, resolve } from "path";
5095
- import { spawnSync as spawnSync9 } from "child_process";
5291
+ import { spawnSync as spawnSync10 } from "child_process";
5096
5292
 
5097
5293
  // src/lib/canon-owned.ts
5098
5294
  var CANON_OWNED = [
@@ -5201,7 +5397,7 @@ function classifyDestinations(cwd, relPaths) {
5201
5397
  const classes = /* @__PURE__ */ new Map();
5202
5398
  const uniqueRelPaths = [...new Set(relPaths)];
5203
5399
  if (uniqueRelPaths.length === 0) return classes;
5204
- const probe = spawnSync9("git", ["rev-parse", "--is-inside-work-tree"], {
5400
+ const probe = spawnSync10("git", ["rev-parse", "--is-inside-work-tree"], {
5205
5401
  cwd,
5206
5402
  encoding: "utf8",
5207
5403
  stdio: ["ignore", "pipe", "ignore"]
@@ -5213,12 +5409,12 @@ function classifyDestinations(cwd, relPaths) {
5213
5409
  }
5214
5410
  return classes;
5215
5411
  }
5216
- const lsFiles = spawnSync9("git", ["ls-files", "-z", "--", ...uniqueRelPaths], {
5412
+ const lsFiles = spawnSync10("git", ["ls-files", "-z", "--", ...uniqueRelPaths], {
5217
5413
  cwd,
5218
5414
  encoding: "utf8",
5219
5415
  stdio: ["ignore", "pipe", "ignore"]
5220
5416
  });
5221
- const status = spawnSync9("git", ["status", "--porcelain=v1", "-z", "--", ...uniqueRelPaths], {
5417
+ const status = spawnSync10("git", ["status", "--porcelain=v1", "-z", "--", ...uniqueRelPaths], {
5222
5418
  cwd,
5223
5419
  encoding: "utf8",
5224
5420
  stdio: ["ignore", "pipe", "ignore"]
@@ -5370,7 +5566,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5370
5566
  cutoverWarnings.push(docsRefsCheckRel);
5371
5567
  }
5372
5568
  const versionPath = join5(cwd, ".canon", "version");
5373
- const newVersion = "3.7.0";
5569
+ const newVersion = "3.9.0";
5374
5570
  const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
5375
5571
  if (currentVersion !== newVersion) {
5376
5572
  pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
@@ -5523,7 +5719,7 @@ function upgradeCmd(args2) {
5523
5719
  process.exit(2);
5524
5720
  }
5525
5721
  if (upgraded.length > 0 && !options.noStage) {
5526
- const r = spawnSync9("git", ["add", ...upgraded], { cwd: process.cwd(), stdio: "inherit" });
5722
+ const r = spawnSync10("git", ["add", ...upgraded], { cwd: process.cwd(), stdio: "inherit" });
5527
5723
  if (r.status !== 0) {
5528
5724
  console.error("\nwarning: failed to stage changes \u2014 run `git add` manually.");
5529
5725
  }
@@ -5648,8 +5844,9 @@ canon run options:
5648
5844
  --interactive, -I Open interactive agent sessions (default: non-interactive)
5649
5845
  --pr At human_review: push branch and open a draft PR (requires gh).
5650
5846
  Auto-commit allow-list: tasks/<id>/**, PIPELINE_TELEMETRY_FILES, and
5651
- managed docs listed in spec.md's "### Affected Files" table. Dirty
5652
- files outside that set die with a remediation message.
5847
+ protected/configured docs listed in spec.md's "### Affected Files"
5848
+ table (all admitted docs once QA is done). Dirty files outside
5849
+ that set die with a remediation message.
5653
5850
  Aborts if HEAD's tree differs from origin/<base> on files not in
5654
5851
  spec's Affected Files (bypass with --force).
5655
5852
  --push At human_review: push branch only, no PR (requires gh). Same allow-list
@@ -5678,7 +5875,7 @@ Global:
5678
5875
  `);
5679
5876
  }
5680
5877
  function printVersion() {
5681
- console.log("3.7.0");
5878
+ console.log("3.9.0");
5682
5879
  }
5683
5880
  switch (command) {
5684
5881
  case "doctor":