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/CHANGELOG.md +22 -0
- package/README.md +39 -0
- package/dist/cli/index.js +878 -681
- package/dist/orchestrator/run-task.js +1058 -840
- package/package.json +1 -1
- package/templates/.canon/README.md +19 -2
- package/templates/.canon/templates/spec.md +2 -2
- package/templates/.claude/agents/spec-review-lens.md +1 -1
- package/templates/.claude/skills/canon-spec/SKILL.md +1 -0
- package/templates/.claude/skills/canon-spec-review/SKILL.md +1 -1
- package/templates/docs/pipeline-orchestrator.md +10 -10
package/dist/cli/index.js
CHANGED
|
@@ -1,28 +1,93 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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/
|
|
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
|
-
|
|
82
|
+
fs3.unlinkSync(path2.join(taskDir, ".heartbeat.json"));
|
|
18
83
|
} catch {
|
|
19
84
|
}
|
|
20
85
|
}
|
|
21
86
|
function readHeartbeatStatus(taskDir) {
|
|
22
|
-
const file =
|
|
87
|
+
const file = path2.join(taskDir, HEARTBEAT_FILENAME);
|
|
23
88
|
let raw;
|
|
24
89
|
try {
|
|
25
|
-
raw =
|
|
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/
|
|
49
|
-
import
|
|
50
|
-
import
|
|
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/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
function
|
|
57
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
96
|
-
|
|
97
|
-
return
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
|
|
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
|
|
108
|
-
return
|
|
264
|
+
function taskDirForRepoRoot(taskId) {
|
|
265
|
+
return path3.join(process.env.CANON_TASKS_DIR_OVERRIDE ?? TASKS_DIR, taskId);
|
|
109
266
|
}
|
|
110
|
-
function
|
|
111
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
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
|
|
1020
|
+
return result;
|
|
816
1021
|
}
|
|
817
|
-
function
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
849
|
-
|
|
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
|
|
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 =
|
|
910
|
-
const heartbeatFile =
|
|
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
|
|
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 =
|
|
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
|
|
1190
|
-
return /canon/i.test(readFileSync(
|
|
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.
|
|
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(
|
|
1402
|
-
if (!existsSync(
|
|
1615
|
+
function readAllowFromSettings(path16) {
|
|
1616
|
+
if (!existsSync(path16)) return { allow: /* @__PURE__ */ new Set(), status: "missing" };
|
|
1403
1617
|
try {
|
|
1404
|
-
const parsed = JSON.parse(readFileSync(
|
|
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
|
|
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 =
|
|
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 (
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
2375
|
-
const stat =
|
|
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 =
|
|
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)
|
|
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
|
|
2676
|
-
import
|
|
2677
|
-
import
|
|
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.
|
|
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 =
|
|
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 (
|
|
2938
|
+
if (path11.resolve(parentToplevel) === path11.resolve(ownToplevel)) {
|
|
2743
2939
|
return upstreamCommit;
|
|
2744
2940
|
}
|
|
2745
|
-
return captureGitOutput(
|
|
2941
|
+
return captureGitOutput(path11.resolve(parentToplevel), ["rev-parse", "HEAD"], runGitAt) || upstreamCommit;
|
|
2746
2942
|
}
|
|
2747
2943
|
function defaultRunCommand(command2, args2) {
|
|
2748
|
-
const result =
|
|
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(
|
|
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(
|
|
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 =
|
|
3014
|
+
const current = fs11.readFileSync(statusFilePath, "utf8");
|
|
2819
3015
|
if (current !== serialized) {
|
|
2820
|
-
|
|
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
|
|
2827
|
-
import
|
|
3022
|
+
import fs14 from "fs";
|
|
3023
|
+
import path14 from "path";
|
|
2828
3024
|
|
|
2829
3025
|
// src/task/templates.ts
|
|
2830
|
-
import
|
|
2831
|
-
import
|
|
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
|
|
3032
|
+
return path12.join(process.cwd(), ".canon", "templates");
|
|
2837
3033
|
}
|
|
2838
3034
|
function taskTemplateOverrideRoot() {
|
|
2839
|
-
return
|
|
3035
|
+
return path12.join(tasksRoot(), "_templates");
|
|
2840
3036
|
}
|
|
2841
3037
|
function resolveTaskTemplateSource(basename2) {
|
|
2842
|
-
const override =
|
|
2843
|
-
if (
|
|
2844
|
-
const managed =
|
|
2845
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
2869
|
-
import
|
|
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 =
|
|
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 ?
|
|
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 =
|
|
3232
|
+
const artifactPath = path13.join(taskDir, config2.artifactName);
|
|
3037
3233
|
let content;
|
|
3038
3234
|
try {
|
|
3039
|
-
content =
|
|
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 =
|
|
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 =
|
|
3282
|
+
const handoffPath = path13.join(taskDir, "handoff.md");
|
|
3087
3283
|
let handoffContent;
|
|
3088
3284
|
try {
|
|
3089
|
-
handoffContent =
|
|
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 =
|
|
3291
|
+
const donePath = path13.join(taskDir, "done.md");
|
|
3096
3292
|
let doneContent = "";
|
|
3097
3293
|
try {
|
|
3098
|
-
doneContent =
|
|
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 =
|
|
3309
|
+
const handoffPath = path13.join(taskDirFor(taskId), "handoff.md");
|
|
3114
3310
|
let content;
|
|
3115
3311
|
try {
|
|
3116
|
-
content =
|
|
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
|
|
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 =
|
|
3428
|
-
if (!
|
|
3623
|
+
const reviewPath = path14.join(taskDir, "review.md");
|
|
3624
|
+
if (!fs14.existsSync(reviewPath)) return null;
|
|
3429
3625
|
if (options.skipUnfilledTemplate) {
|
|
3430
|
-
const content =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
3697
|
+
return path15.join(tasksRoot(), taskId);
|
|
3502
3698
|
}
|
|
3503
3699
|
function taskDirForCwd(_cwd, taskId) {
|
|
3504
3700
|
const root = tasksRoot();
|
|
3505
|
-
if (
|
|
3506
|
-
return
|
|
3701
|
+
if (path15.isAbsolute(root)) {
|
|
3702
|
+
return path15.join(root, taskId);
|
|
3507
3703
|
}
|
|
3508
|
-
return
|
|
3704
|
+
return path15.join(resolveTaskCwd(taskId), root, taskId);
|
|
3509
3705
|
}
|
|
3510
3706
|
function taskStatusFileForCwd(cwd, taskId) {
|
|
3511
|
-
return
|
|
3707
|
+
return path15.join(taskDirForCwd(cwd, taskId), "status.json");
|
|
3512
3708
|
}
|
|
3513
3709
|
function taskRootForGate(cwd) {
|
|
3514
3710
|
const root = tasksRoot();
|
|
3515
|
-
return
|
|
3711
|
+
return path15.isAbsolute(root) ? root : path15.join(cwd, root);
|
|
3516
3712
|
}
|
|
3517
3713
|
function readJsonFile(filePath) {
|
|
3518
3714
|
try {
|
|
3519
|
-
return JSON.parse(
|
|
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
|
-
|
|
3723
|
+
fs15.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
|
|
3528
3724
|
`, "utf8");
|
|
3529
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
3766
|
+
if (!fs15.existsSync(root)) {
|
|
3571
3767
|
throw new Error(`Error: templates directory not found at ${root}`);
|
|
3572
3768
|
}
|
|
3573
|
-
return
|
|
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
|
|
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 (
|
|
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
|
-
|
|
3818
|
+
fs15.mkdirSync(taskDir, { recursive: true });
|
|
3623
3819
|
for (const basename2 of listTemplateFiles()) {
|
|
3624
|
-
const source = resolveTaskTemplateSource(basename2) ??
|
|
3625
|
-
|
|
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 =
|
|
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 (!
|
|
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
|
|
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(
|
|
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 ${
|
|
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 =
|
|
3672
|
-
if (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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,
|
|
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
|
-
|
|
3988
|
-
|
|
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 =
|
|
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 (
|
|
4013
|
-
|
|
4208
|
+
if (fs15.existsSync(notesPath)) {
|
|
4209
|
+
fs15.appendFileSync(notesPath, `
|
|
4014
4210
|
${noteLine}
|
|
4015
4211
|
`, "utf8");
|
|
4016
4212
|
} else {
|
|
4017
|
-
|
|
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,
|
|
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
|
-
|
|
4166
|
-
|
|
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 =
|
|
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 (
|
|
4189
|
-
|
|
4384
|
+
if (fs15.existsSync(notesPath)) {
|
|
4385
|
+
fs15.appendFileSync(notesPath, `
|
|
4190
4386
|
${noteLine}
|
|
4191
4387
|
`, "utf8");
|
|
4192
4388
|
} else {
|
|
4193
|
-
|
|
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 =
|
|
4213
|
-
if (!
|
|
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 =
|
|
4217
|
-
if (
|
|
4412
|
+
const reviewPath = path15.join(taskDir, "spec-review.md");
|
|
4413
|
+
if (fs15.existsSync(reviewPath)) {
|
|
4218
4414
|
let n = 1;
|
|
4219
|
-
while (
|
|
4220
|
-
|
|
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 =
|
|
4244
|
-
if (!
|
|
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 ${
|
|
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 =
|
|
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 =
|
|
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 =
|
|
4501
|
+
const rootAbsolute = path15.isAbsolute(root) ? root : path15.resolve(repoRootForPaths, root);
|
|
4306
4502
|
const canonicalRoot = safeRealpath(rootAbsolute);
|
|
4307
|
-
const taskCanonical =
|
|
4503
|
+
const taskCanonical = path15.join(canonicalRoot, taskId);
|
|
4308
4504
|
if (canonicalDirty === taskCanonical) return true;
|
|
4309
|
-
if (canonicalDirty.startsWith(`${taskCanonical}${
|
|
4505
|
+
if (canonicalDirty.startsWith(`${taskCanonical}${path15.sep}`)) return true;
|
|
4310
4506
|
for (const telemetry of PIPELINE_TELEMETRY_FILES) {
|
|
4311
|
-
const telemetryAbsolute =
|
|
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
|
|
4522
|
+
return path15.dirname(gitCommonDir);
|
|
4327
4523
|
}
|
|
4328
4524
|
function safeRealpath(target) {
|
|
4329
4525
|
try {
|
|
4330
|
-
return
|
|
4526
|
+
return fs15.realpathSync(target);
|
|
4331
4527
|
} catch {
|
|
4332
|
-
const parent =
|
|
4528
|
+
const parent = path15.dirname(target);
|
|
4333
4529
|
if (parent === target) return target;
|
|
4334
4530
|
try {
|
|
4335
|
-
return
|
|
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 (!
|
|
4639
|
+
if (!fs15.existsSync(root)) return;
|
|
4444
4640
|
const shippable = [];
|
|
4445
|
-
for (const entry of
|
|
4641
|
+
for (const entry of fs15.readdirSync(root).sort()) {
|
|
4446
4642
|
if (entry === "_archive" || entry.startsWith("_")) continue;
|
|
4447
|
-
const statusPath =
|
|
4448
|
-
if (!
|
|
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 (!
|
|
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
|
|
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 =
|
|
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.
|
|
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 =
|
|
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) =>
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
5652
|
-
|
|
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.
|
|
5878
|
+
console.log("3.9.0");
|
|
5682
5879
|
}
|
|
5683
5880
|
switch (command) {
|
|
5684
5881
|
case "doctor":
|