canon-ai 3.3.0 → 3.4.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 +30 -0
- package/dist/cli/index.js +70 -68
- package/dist/orchestrator/run-task.js +460 -177
- package/package.json +1 -1
- package/templates/.canon/templates/handoff.md +1 -1
- package/templates/.canon/templates/review.md +4 -0
- package/templates/.claude/agents/code-review-anchored.md +2 -0
- package/templates/.claude/agents/code-review-cold.md +4 -2
- package/templates/docs/pipeline-orchestrator.md +9 -21
- package/templates/scripts/docs-refs-check.mjs +6 -3
|
@@ -79,12 +79,12 @@ import { pathToFileURL } from "url";
|
|
|
79
79
|
|
|
80
80
|
// src/orchestrator/main.ts
|
|
81
81
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
82
|
-
import
|
|
82
|
+
import fs21 from "fs";
|
|
83
83
|
import os from "os";
|
|
84
84
|
import path20 from "path";
|
|
85
85
|
|
|
86
86
|
// src/orchestrator/phases/code-review.ts
|
|
87
|
-
import
|
|
87
|
+
import fs16 from "fs";
|
|
88
88
|
import path15 from "path";
|
|
89
89
|
|
|
90
90
|
// src/orchestrator/cli.ts
|
|
@@ -313,6 +313,7 @@ function isSynchronousMode(args) {
|
|
|
313
313
|
|
|
314
314
|
// src/orchestrator/git.ts
|
|
315
315
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
316
|
+
import fs6 from "fs";
|
|
316
317
|
import path5 from "path";
|
|
317
318
|
|
|
318
319
|
// src/orchestrator/env.ts
|
|
@@ -1329,6 +1330,23 @@ function filterGitIgnoredPaths(paths, cwd) {
|
|
|
1329
1330
|
const stdout = result.stdout ?? "";
|
|
1330
1331
|
return new Set(stdout.split("\0").filter((p) => p.length > 0));
|
|
1331
1332
|
}
|
|
1333
|
+
function filterStageablePaths(paths, cwd) {
|
|
1334
|
+
if (paths.length === 0) return [];
|
|
1335
|
+
return paths.filter((candidate) => {
|
|
1336
|
+
const probe = spawnSync3("git", ["ls-files", "--error-unmatch", "--", candidate], {
|
|
1337
|
+
cwd,
|
|
1338
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
1339
|
+
});
|
|
1340
|
+
if (probe.error || probe.status !== 1) return true;
|
|
1341
|
+
try {
|
|
1342
|
+
fs6.lstatSync(path5.join(cwd, candidate));
|
|
1343
|
+
return true;
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
const code = error.code;
|
|
1346
|
+
return code !== "ENOENT" && code !== "ENOTDIR";
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1332
1350
|
function commitTaskArtifactsToBase(taskIds, _artifactFiles) {
|
|
1333
1351
|
void _artifactFiles;
|
|
1334
1352
|
for (const taskId of taskIds) {
|
|
@@ -1410,7 +1428,10 @@ function truncateUtf8(input, capBytes) {
|
|
|
1410
1428
|
return bytes.subarray(0, end).toString("utf8");
|
|
1411
1429
|
}
|
|
1412
1430
|
function getScopedDiff(baseBranch, cwd, capBytes = 5e4) {
|
|
1413
|
-
|
|
1431
|
+
return getScopedDiffInRange(`${baseBranch}...HEAD`, cwd, capBytes);
|
|
1432
|
+
}
|
|
1433
|
+
function getScopedDiffInRange(rangeExpr, cwd, capBytes = 5e4) {
|
|
1434
|
+
const result = gitSafeAtRaw(cwd, "diff", rangeExpr);
|
|
1414
1435
|
if (!result.ok) return null;
|
|
1415
1436
|
const raw = result.stdout;
|
|
1416
1437
|
if (Buffer.byteLength(raw, "utf8") <= capBytes) {
|
|
@@ -1574,10 +1595,34 @@ function parseNameStatusOutput(raw) {
|
|
|
1574
1595
|
return [...paths].sort();
|
|
1575
1596
|
}
|
|
1576
1597
|
function getAffectedFiles(baseRef, cwd) {
|
|
1577
|
-
|
|
1578
|
-
|
|
1598
|
+
return getPathsInRange(`${baseRef}...HEAD`, cwd) ?? [];
|
|
1599
|
+
}
|
|
1600
|
+
function getPathsInRange(rangeExpr, cwd) {
|
|
1601
|
+
const result = gitSafeAtRaw(cwd, "diff", rangeExpr, "--name-status", "-M", "-z");
|
|
1602
|
+
if (!result.ok) return null;
|
|
1603
|
+
if (!result.stdout) return [];
|
|
1579
1604
|
return parseNameStatusOutput(result.stdout);
|
|
1580
1605
|
}
|
|
1606
|
+
function resolveCommit(ref, cwd) {
|
|
1607
|
+
const result = gitSafeAt(cwd, "rev-parse", "--verify", `${ref}^{commit}`);
|
|
1608
|
+
return result.ok ? result.stdout.trim() : null;
|
|
1609
|
+
}
|
|
1610
|
+
function isAncestorCommit(ancestorRef, descendantRef, cwd) {
|
|
1611
|
+
return gitSafeAt(cwd, "merge-base", "--is-ancestor", ancestorRef, descendantRef).ok;
|
|
1612
|
+
}
|
|
1613
|
+
function getDeltaLineStats(prevSha, cwd, headRef = "HEAD") {
|
|
1614
|
+
const result = gitSafeAtRaw(cwd, "diff", `${prevSha}..${headRef}`, "--numstat", "--no-renames");
|
|
1615
|
+
if (!result.ok) return null;
|
|
1616
|
+
const stats = result.stdout.split("\n").filter(Boolean).map((line) => {
|
|
1617
|
+
const [addedRaw, deletedRaw, ...pathParts] = line.split(" ");
|
|
1618
|
+
return {
|
|
1619
|
+
path: pathParts.join(" "),
|
|
1620
|
+
added: addedRaw === "-" ? 0 : Number(addedRaw),
|
|
1621
|
+
deleted: deletedRaw === "-" ? 0 : Number(deletedRaw)
|
|
1622
|
+
};
|
|
1623
|
+
});
|
|
1624
|
+
return stats.every((stat) => stat.path.length > 0 && Number.isFinite(stat.added) && Number.isFinite(stat.deleted)) ? stats : null;
|
|
1625
|
+
}
|
|
1581
1626
|
function getTreeDriftFiles(baseRef, cwd) {
|
|
1582
1627
|
const result = gitSafeAtRaw(cwd, "diff", baseRef, "HEAD", "--name-status", "-M", "-z");
|
|
1583
1628
|
if (!result.ok) {
|
|
@@ -1587,6 +1632,31 @@ function getTreeDriftFiles(baseRef, cwd) {
|
|
|
1587
1632
|
}
|
|
1588
1633
|
|
|
1589
1634
|
// src/lib/pipeline-policy.ts
|
|
1635
|
+
var CODE_REVIEW_DELTA_LINE_THRESHOLD = 400;
|
|
1636
|
+
function isReviewOwnedPath(filePath, facts) {
|
|
1637
|
+
return facts.taskIds.some((id) => filePath === `tasks/${id}` || filePath.startsWith(`tasks/${id}/`)) || facts.telemetryFiles.includes(filePath);
|
|
1638
|
+
}
|
|
1639
|
+
function resolveCodeReviewScope(facts) {
|
|
1640
|
+
const full = (reason) => ({ scope: "full", base: facts.baseBranch, reason });
|
|
1641
|
+
if (facts.isRound1) return full("Round 1 (initial review)");
|
|
1642
|
+
if (facts.effectiveSize === "XL" || facts.delicate) return full(facts.delicate ? "delicate" : "XL task size");
|
|
1643
|
+
const prev = facts.prevRecord;
|
|
1644
|
+
if (prev === null) return full(facts.previousRecordsDisagree ? "bundle members disagree on the previous reviewed commit" : facts.previousArchiveMalformed ? "unparseable cold-Codex archive record for the previous round" : "no cold-Codex archive record for the previous round");
|
|
1645
|
+
if (!prev.exists) return full(`previous reviewed commit ${prev.reviewedSha} does not resolve`);
|
|
1646
|
+
if (!prev.isAncestor) return full(`previous reviewed commit ${prev.reviewedSha} is not an ancestor of HEAD`);
|
|
1647
|
+
if (prev.equalsHead) return full("previous reviewed commit equals HEAD");
|
|
1648
|
+
if (facts.deltaPaths === null) return full("delta path probe failed");
|
|
1649
|
+
if (facts.priorChangeSetPaths === null) return full("previous change-set path probe failed");
|
|
1650
|
+
if (facts.deltaFileStats === null) return full("delta line-stat probe failed");
|
|
1651
|
+
const priorPaths = new Set(facts.priorChangeSetPaths.filter((p) => !isReviewOwnedPath(p, facts)));
|
|
1652
|
+
const outside = facts.deltaPaths.find((p) => !isReviewOwnedPath(p, facts) && !priorPaths.has(p));
|
|
1653
|
+
if (outside) return full(`${outside} is outside the previous round's change set`);
|
|
1654
|
+
const lines = facts.deltaFileStats.filter((stat) => !isReviewOwnedPath(stat.path, facts)).reduce((sum, stat) => sum + stat.added + stat.deleted, 0);
|
|
1655
|
+
if (lines > CODE_REVIEW_DELTA_LINE_THRESHOLD) {
|
|
1656
|
+
return full(`delta line count ${lines} exceeds ${CODE_REVIEW_DELTA_LINE_THRESHOLD}`);
|
|
1657
|
+
}
|
|
1658
|
+
return { scope: "delta", base: prev.reviewedSha, reason: "delta" };
|
|
1659
|
+
}
|
|
1590
1660
|
var SIZE_ORDER = ["XS", "S", "M", "L", "XL"];
|
|
1591
1661
|
var SINGLE_PASS_BUDGET_BY_SIZE = {
|
|
1592
1662
|
XS: "5.00",
|
|
@@ -1790,15 +1860,15 @@ function isPlanCombined2(status) {
|
|
|
1790
1860
|
import { spawn as spawn2 } from "child_process";
|
|
1791
1861
|
|
|
1792
1862
|
// src/orchestrator/metrics.ts
|
|
1793
|
-
import
|
|
1863
|
+
import fs7 from "fs";
|
|
1794
1864
|
import path6 from "path";
|
|
1795
1865
|
function getMetricsFile(activeCwd) {
|
|
1796
1866
|
return process.env.CANON_METRICS_FILE_OVERRIDE ? path6.resolve(process.env.CANON_METRICS_FILE_OVERRIDE) : path6.join(activeCwd ?? REPO_ROOT, "docs/pipeline-invocations.md");
|
|
1797
1867
|
}
|
|
1798
1868
|
function recordMetric(entry) {
|
|
1799
1869
|
const metricsFile = getMetricsFile(entry.activeCwd);
|
|
1800
|
-
if (!
|
|
1801
|
-
|
|
1870
|
+
if (!fs7.existsSync(metricsFile)) {
|
|
1871
|
+
fs7.writeFileSync(metricsFile, [
|
|
1802
1872
|
"# Workflow Metrics",
|
|
1803
1873
|
"",
|
|
1804
1874
|
"> Auto-logged by canon's orchestrator. One row per agent invocation.",
|
|
@@ -1812,7 +1882,7 @@ function recordMetric(entry) {
|
|
|
1812
1882
|
const safeCell = (v) => v.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
1813
1883
|
const dur = (entry.durationMs / 1e3).toFixed(1) + "s";
|
|
1814
1884
|
const tok = entry.tokens != null ? String(entry.tokens) : "-";
|
|
1815
|
-
|
|
1885
|
+
fs7.appendFileSync(
|
|
1816
1886
|
metricsFile,
|
|
1817
1887
|
`| ${(/* @__PURE__ */ new Date()).toISOString()} | ${entry.taskId} | ${entry.phase} | ${entry.agent} | ${safeCell(entry.model)} | ${entry.iteration ?? "-"} | ${dur} | ${tok} | ${entry.status} |
|
|
1818
1888
|
`
|
|
@@ -2434,7 +2504,7 @@ function evaluateCodeReviewLoop(tasks, cap) {
|
|
|
2434
2504
|
}
|
|
2435
2505
|
|
|
2436
2506
|
// src/orchestrator/validation.ts
|
|
2437
|
-
import
|
|
2507
|
+
import fs8 from "fs";
|
|
2438
2508
|
import path7 from "path";
|
|
2439
2509
|
|
|
2440
2510
|
// src/orchestrator/markdown-table.ts
|
|
@@ -2751,7 +2821,7 @@ function normalizeCheckLabel(value) {
|
|
|
2751
2821
|
}
|
|
2752
2822
|
function parseValidationRequiredChecks(specPath) {
|
|
2753
2823
|
try {
|
|
2754
|
-
const content =
|
|
2824
|
+
const content = fs8.readFileSync(specPath, "utf8");
|
|
2755
2825
|
const section = content.match(/## Validation Required\n\n([\s\S]*?)(?:\n## |\n# |$)/);
|
|
2756
2826
|
if (!section) return null;
|
|
2757
2827
|
const checks = [];
|
|
@@ -2838,16 +2908,17 @@ function checkRerouteEvidence(phase, artifactContent, status) {
|
|
|
2838
2908
|
}
|
|
2839
2909
|
return { reroute: true, ok: true };
|
|
2840
2910
|
}
|
|
2911
|
+
var HUMAN_AMENDMENT_HEADING_RE = /^#{2,6}[ \t]+Amendment\b(?![ \t]*\(full-send scope\))/im;
|
|
2841
2912
|
function verifyRerouteAmendment(taskId, requiredRound) {
|
|
2842
2913
|
const specPath = path7.join(taskDirFor(taskId), "spec.md");
|
|
2843
2914
|
let content;
|
|
2844
2915
|
try {
|
|
2845
|
-
content =
|
|
2916
|
+
content = fs8.readFileSync(specPath, "utf8");
|
|
2846
2917
|
} catch {
|
|
2847
2918
|
return { amended: false, reason: `spec.md missing at ${specPath}` };
|
|
2848
2919
|
}
|
|
2849
2920
|
if (requiredRound === 1) {
|
|
2850
|
-
if (
|
|
2921
|
+
if (HUMAN_AMENDMENT_HEADING_RE.test(content)) {
|
|
2851
2922
|
return { amended: true, reason: "" };
|
|
2852
2923
|
}
|
|
2853
2924
|
return {
|
|
@@ -2872,7 +2943,7 @@ function verifyRerouteAmendment(taskId, requiredRound) {
|
|
|
2872
2943
|
reason: `found \`## Amendment Round ${seenRound}\` in ${specPath}, expected \`## Amendment Round ${requiredRound}\``
|
|
2873
2944
|
};
|
|
2874
2945
|
}
|
|
2875
|
-
if (
|
|
2946
|
+
if (HUMAN_AMENDMENT_HEADING_RE.test(content)) {
|
|
2876
2947
|
return {
|
|
2877
2948
|
amended: false,
|
|
2878
2949
|
reason: `found \`## Amendment\` in ${specPath}, expected \`## Amendment Round ${requiredRound}\``
|
|
@@ -3017,7 +3088,7 @@ function classifyPreflightBlockers(taskId, changedFiles, bundleDiffIssues = [])
|
|
|
3017
3088
|
const handoffPath = path7.join(taskDirFor(taskId), "handoff.md");
|
|
3018
3089
|
const specPath = path7.join(taskDirFor(taskId), "spec.md");
|
|
3019
3090
|
try {
|
|
3020
|
-
const content =
|
|
3091
|
+
const content = fs8.readFileSync(handoffPath, "utf8");
|
|
3021
3092
|
const latestResults = computeLatestValidationResults(content);
|
|
3022
3093
|
const requiredChecks = parseValidationRequiredChecks(specPath);
|
|
3023
3094
|
const { malformed } = parseHandoffChangesRows(taskId);
|
|
@@ -3049,7 +3120,7 @@ function validateHandoffAgainstSpec(specPath, handoffPath, latestResults, change
|
|
|
3049
3120
|
rowMap = latestResults;
|
|
3050
3121
|
} else {
|
|
3051
3122
|
try {
|
|
3052
|
-
const content =
|
|
3123
|
+
const content = fs8.readFileSync(handoffPath, "utf8");
|
|
3053
3124
|
rowMap = computeLatestValidationResults(content);
|
|
3054
3125
|
} catch {
|
|
3055
3126
|
rowMap = /* @__PURE__ */ new Map();
|
|
@@ -3103,7 +3174,7 @@ function isTemplateUnfilled(content) {
|
|
|
3103
3174
|
function isDoneMdTemplate(donePath) {
|
|
3104
3175
|
let content;
|
|
3105
3176
|
try {
|
|
3106
|
-
content =
|
|
3177
|
+
content = fs8.readFileSync(donePath, "utf8");
|
|
3107
3178
|
} catch {
|
|
3108
3179
|
return true;
|
|
3109
3180
|
}
|
|
@@ -3112,7 +3183,7 @@ function isDoneMdTemplate(donePath) {
|
|
|
3112
3183
|
function isPrBodyTemplate(prBodyPath) {
|
|
3113
3184
|
let content;
|
|
3114
3185
|
try {
|
|
3115
|
-
content =
|
|
3186
|
+
content = fs8.readFileSync(prBodyPath, "utf8");
|
|
3116
3187
|
} catch {
|
|
3117
3188
|
return true;
|
|
3118
3189
|
}
|
|
@@ -3158,7 +3229,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3158
3229
|
const artifactPath = path7.join(taskDir, config3.artifactName);
|
|
3159
3230
|
let content;
|
|
3160
3231
|
try {
|
|
3161
|
-
content =
|
|
3232
|
+
content = fs8.readFileSync(artifactPath, "utf8");
|
|
3162
3233
|
} catch {
|
|
3163
3234
|
return { ok: false, reason: `${config3.artifactName} is missing for phase '${phase}'` };
|
|
3164
3235
|
}
|
|
@@ -3170,7 +3241,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3170
3241
|
if (phase === "spec_review" || phase === "plan") {
|
|
3171
3242
|
let statusRaw;
|
|
3172
3243
|
try {
|
|
3173
|
-
statusRaw =
|
|
3244
|
+
statusRaw = fs8.readFileSync(path7.join(taskDir, "status.json"), "utf8");
|
|
3174
3245
|
} catch {
|
|
3175
3246
|
return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
|
|
3176
3247
|
}
|
|
@@ -3208,7 +3279,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3208
3279
|
const handoffPath = path7.join(taskDir, "handoff.md");
|
|
3209
3280
|
let handoffContent;
|
|
3210
3281
|
try {
|
|
3211
|
-
handoffContent =
|
|
3282
|
+
handoffContent = fs8.readFileSync(handoffPath, "utf8");
|
|
3212
3283
|
} catch {
|
|
3213
3284
|
return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
|
|
3214
3285
|
}
|
|
@@ -3217,7 +3288,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3217
3288
|
const donePath = path7.join(taskDir, "done.md");
|
|
3218
3289
|
let doneContent = "";
|
|
3219
3290
|
try {
|
|
3220
|
-
doneContent =
|
|
3291
|
+
doneContent = fs8.readFileSync(donePath, "utf8");
|
|
3221
3292
|
} catch {
|
|
3222
3293
|
}
|
|
3223
3294
|
if (hasHumanPendingWaiver(doneContent)) return { ok: true };
|
|
@@ -3238,7 +3309,7 @@ function parseHandoffChangesRows(taskId) {
|
|
|
3238
3309
|
const handoffPath = path7.join(taskDirFor(taskId), "handoff.md");
|
|
3239
3310
|
let content;
|
|
3240
3311
|
try {
|
|
3241
|
-
content =
|
|
3312
|
+
content = fs8.readFileSync(handoffPath, "utf8");
|
|
3242
3313
|
} catch {
|
|
3243
3314
|
return { files: [], malformed: [] };
|
|
3244
3315
|
}
|
|
@@ -3265,7 +3336,7 @@ function parseAffectedFilesFromSpec(taskId) {
|
|
|
3265
3336
|
const specPath = path7.join(taskDirFor(taskId), "spec.md");
|
|
3266
3337
|
let content;
|
|
3267
3338
|
try {
|
|
3268
|
-
content =
|
|
3339
|
+
content = fs8.readFileSync(specPath, "utf8");
|
|
3269
3340
|
} catch {
|
|
3270
3341
|
return { files: [], malformed: [] };
|
|
3271
3342
|
}
|
|
@@ -3567,6 +3638,24 @@ function verifyHandoffAgainstDiffFromData(taskIds, inputs) {
|
|
|
3567
3638
|
}
|
|
3568
3639
|
return issues;
|
|
3569
3640
|
}
|
|
3641
|
+
function buildAffectedFilesAllowlist(taskIds, options) {
|
|
3642
|
+
const paths = new Set(PIPELINE_TELEMETRY_FILES);
|
|
3643
|
+
const prefixes = [];
|
|
3644
|
+
for (const taskId of taskIds) {
|
|
3645
|
+
const parsed = parseAffectedFilesFromSpec(taskId);
|
|
3646
|
+
for (const filePath of parsed.files) {
|
|
3647
|
+
if (filePath.endsWith("/")) prefixes.push(filePath);
|
|
3648
|
+
else paths.add(filePath);
|
|
3649
|
+
}
|
|
3650
|
+
for (const malformed of parsed.malformed) {
|
|
3651
|
+
warn(`${taskId} spec.md Affected Files row malformed: ${malformed.reason}`);
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
if (options.admitManagedDocs) {
|
|
3655
|
+
for (const doc of PIPELINE_MANAGED_DOCS) paths.add(doc);
|
|
3656
|
+
}
|
|
3657
|
+
return { paths, prefixes };
|
|
3658
|
+
}
|
|
3570
3659
|
function verifyBaseDriftFromData(diffFiles, allowedPaths, taskIds, allowedPrefixes = []) {
|
|
3571
3660
|
const drift = [];
|
|
3572
3661
|
for (const filePath of diffFiles) {
|
|
@@ -3577,6 +3666,35 @@ function verifyBaseDriftFromData(diffFiles, allowedPaths, taskIds, allowedPrefix
|
|
|
3577
3666
|
}
|
|
3578
3667
|
return drift;
|
|
3579
3668
|
}
|
|
3669
|
+
function classifyBaseDriftFilesFromData(driftFiles, taskChangedFiles) {
|
|
3670
|
+
const baseAdvanced = [];
|
|
3671
|
+
const taskChangedOutOfScope = [];
|
|
3672
|
+
for (const filePath of driftFiles) {
|
|
3673
|
+
if (taskChangedFiles.has(filePath)) taskChangedOutOfScope.push(filePath);
|
|
3674
|
+
else baseAdvanced.push(filePath);
|
|
3675
|
+
}
|
|
3676
|
+
return { baseAdvanced, taskChangedOutOfScope };
|
|
3677
|
+
}
|
|
3678
|
+
function buildBaseDriftAbortMessage(baseBranch, classification) {
|
|
3679
|
+
const sections = ["--pr aborted: base-drift detected."];
|
|
3680
|
+
if (classification.baseAdvanced.length > 0) sections.push(
|
|
3681
|
+
`The base branch advanced. These files changed only on origin/${baseBranch}:
|
|
3682
|
+
` + classification.baseAdvanced.map((file) => ` ${file}`).join("\n") + `
|
|
3683
|
+
Merge or rebase the base branch into the task branch, then rerun:
|
|
3684
|
+
git fetch origin ${baseBranch} && git rebase origin/${baseBranch}
|
|
3685
|
+
(or: git merge origin/${baseBranch})`
|
|
3686
|
+
);
|
|
3687
|
+
if (classification.taskChangedOutOfScope.length > 0) sections.push(
|
|
3688
|
+
`These task-changed files are not in the spec's Affected Files:
|
|
3689
|
+
` + classification.taskChangedOutOfScope.map((file) => ` ${file}`).join("\n") + `
|
|
3690
|
+
The allowlist covers task artifacts, pipeline telemetry (PIPELINE_TELEMETRY_FILES), and spec Affected Files entries, including directory entries.
|
|
3691
|
+
Add legitimate paths to the spec's '### Affected Files' table (both sides of a rename), then rerun. If a sibling pipeline or stray task-branch commit introduced a file, remove it with:
|
|
3692
|
+
git checkout origin/${baseBranch} -- <path> && git commit -m 'revert drift on <path>'
|
|
3693
|
+
or: git revert <sha>`
|
|
3694
|
+
);
|
|
3695
|
+
sections.push(`Bypass with --force if you've verified the drift is intentional.`);
|
|
3696
|
+
return sections.join("\n\n");
|
|
3697
|
+
}
|
|
3580
3698
|
function parseDiffNameStatus(stdout) {
|
|
3581
3699
|
const diffFiles = [];
|
|
3582
3700
|
const renamePairs = [];
|
|
@@ -3608,7 +3726,7 @@ function verifyHandoffAgainstDiff(taskIds, baseRef) {
|
|
|
3608
3726
|
for (const taskId of taskIds) {
|
|
3609
3727
|
let content;
|
|
3610
3728
|
try {
|
|
3611
|
-
content =
|
|
3729
|
+
content = fs8.readFileSync(path7.join(taskDirFor(taskId), "handoff.md"), "utf8");
|
|
3612
3730
|
} catch {
|
|
3613
3731
|
continue;
|
|
3614
3732
|
}
|
|
@@ -3640,31 +3758,18 @@ function verifyBaseDrift(taskIds, baseBranch, cwd) {
|
|
|
3640
3758
|
if (!driftResult.ok) {
|
|
3641
3759
|
return { drift: [], fetchFailed: false, diffFailed: true, diffError: driftResult.stderr };
|
|
3642
3760
|
}
|
|
3643
|
-
|
|
3644
|
-
const allowedPrefixes = [];
|
|
3761
|
+
let admitManagedDocs = false;
|
|
3645
3762
|
for (const taskId of taskIds) {
|
|
3646
|
-
const parsed = parseAffectedFilesFromSpec(taskId);
|
|
3647
|
-
for (const filePath of parsed.files) {
|
|
3648
|
-
if (filePath.endsWith("/")) {
|
|
3649
|
-
allowedPrefixes.push(filePath);
|
|
3650
|
-
} else {
|
|
3651
|
-
allowedPaths.add(filePath);
|
|
3652
|
-
}
|
|
3653
|
-
}
|
|
3654
|
-
for (const malformed of parsed.malformed) {
|
|
3655
|
-
warn(`${taskId} spec.md Affected Files row malformed: ${malformed.reason}`);
|
|
3656
|
-
}
|
|
3657
3763
|
try {
|
|
3658
3764
|
if (readStatus(taskId).phases.qa?.status === "done") {
|
|
3659
|
-
|
|
3660
|
-
allowedPaths.add(doc);
|
|
3661
|
-
}
|
|
3765
|
+
admitManagedDocs = true;
|
|
3662
3766
|
}
|
|
3663
3767
|
} catch {
|
|
3664
3768
|
}
|
|
3665
3769
|
}
|
|
3770
|
+
const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs });
|
|
3666
3771
|
return {
|
|
3667
|
-
drift: verifyBaseDriftFromData(driftResult.files,
|
|
3772
|
+
drift: verifyBaseDriftFromData(driftResult.files, allowlist.paths, taskIds, allowlist.prefixes),
|
|
3668
3773
|
fetchFailed: false,
|
|
3669
3774
|
diffFailed: false
|
|
3670
3775
|
};
|
|
@@ -3682,7 +3787,7 @@ function verifyBaseDivergenceFromData(commits) {
|
|
|
3682
3787
|
function verifyBaseDivergence(baseBranch, cwd) {
|
|
3683
3788
|
const fetchResult = gitSafeAt(cwd, "fetch", "origin", baseBranch);
|
|
3684
3789
|
if (!fetchResult.ok) {
|
|
3685
|
-
if (!
|
|
3790
|
+
if (!fs8.existsSync(cwd)) {
|
|
3686
3791
|
return { commits: [], ok: false, stderr: fetchResult.stderr, fetchFailed: false };
|
|
3687
3792
|
}
|
|
3688
3793
|
warn(
|
|
@@ -3766,11 +3871,11 @@ function buildSharedDocAbortMessage(abortedFiles) {
|
|
|
3766
3871
|
}
|
|
3767
3872
|
|
|
3768
3873
|
// src/orchestrator/prompts/index.ts
|
|
3769
|
-
import
|
|
3874
|
+
import fs12 from "fs";
|
|
3770
3875
|
import path11 from "path";
|
|
3771
3876
|
|
|
3772
3877
|
// src/orchestrator/context.ts
|
|
3773
|
-
import
|
|
3878
|
+
import fs9 from "fs";
|
|
3774
3879
|
import path8 from "path";
|
|
3775
3880
|
function extractAffectedFiles(taskId) {
|
|
3776
3881
|
try {
|
|
@@ -3784,7 +3889,7 @@ function isSafeRepoPath(file) {
|
|
|
3784
3889
|
const resolved = path8.resolve(REPO_ROOT, file);
|
|
3785
3890
|
if (!resolved.startsWith(REPO_ROOT + path8.sep)) return false;
|
|
3786
3891
|
try {
|
|
3787
|
-
const real =
|
|
3892
|
+
const real = fs9.realpathSync(resolved);
|
|
3788
3893
|
if (!real.startsWith(REPO_ROOT + path8.sep)) return false;
|
|
3789
3894
|
} catch {
|
|
3790
3895
|
}
|
|
@@ -3798,7 +3903,7 @@ function buildContextBlock(taskIds) {
|
|
|
3798
3903
|
if (!isSafeRepoPath(file)) continue;
|
|
3799
3904
|
const filePath = path8.join(REPO_ROOT, file);
|
|
3800
3905
|
try {
|
|
3801
|
-
allFiles.set(file,
|
|
3906
|
+
allFiles.set(file, fs9.readFileSync(filePath, "utf8"));
|
|
3802
3907
|
} catch {
|
|
3803
3908
|
}
|
|
3804
3909
|
}
|
|
@@ -3829,7 +3934,7 @@ ${content}
|
|
|
3829
3934
|
function buildKnownPitfalls(taskIds) {
|
|
3830
3935
|
const patternsPath = process.env.CANON_PATTERNS_MD_PATH ?? path8.join(getActiveCwd(taskIds), "docs/patterns.md");
|
|
3831
3936
|
try {
|
|
3832
|
-
const content =
|
|
3937
|
+
const content = fs9.readFileSync(patternsPath, "utf8");
|
|
3833
3938
|
const match = content.match(/## Known Pitfalls\n\n([\s\S]*?)(?:\n## |\n---|\n# |$)/);
|
|
3834
3939
|
if (!match) return "";
|
|
3835
3940
|
return `
|
|
@@ -3846,7 +3951,7 @@ function buildKnownRisks(taskIds) {
|
|
|
3846
3951
|
const riskBlocks = taskIds.map((taskId) => {
|
|
3847
3952
|
const specPath = path8.join(taskDirFor(taskId), "spec.md");
|
|
3848
3953
|
try {
|
|
3849
|
-
const content =
|
|
3954
|
+
const content = fs9.readFileSync(specPath, "utf8");
|
|
3850
3955
|
const match = content.match(/## Known Risks\n\n([\s\S]*?)(?:\n## |\n# |$)/);
|
|
3851
3956
|
if (!match) return "";
|
|
3852
3957
|
const risks = match[1].trim();
|
|
@@ -3873,7 +3978,7 @@ function summarizePreloadStatus(taskIds) {
|
|
|
3873
3978
|
if (!isSafeRepoPath(file)) continue;
|
|
3874
3979
|
const filePath = path8.join(REPO_ROOT, file);
|
|
3875
3980
|
try {
|
|
3876
|
-
files.set(file,
|
|
3981
|
+
files.set(file, fs9.statSync(filePath).size);
|
|
3877
3982
|
} catch {
|
|
3878
3983
|
files.set(file, 0);
|
|
3879
3984
|
}
|
|
@@ -3890,7 +3995,7 @@ function summarizePreloadStatus(taskIds) {
|
|
|
3890
3995
|
function extractValidationChecks(taskId) {
|
|
3891
3996
|
const specPath = path8.join(taskDirFor(taskId), "spec.md");
|
|
3892
3997
|
try {
|
|
3893
|
-
const content =
|
|
3998
|
+
const content = fs9.readFileSync(specPath, "utf8");
|
|
3894
3999
|
const section = content.match(/## Validation Required\n\n([\s\S]*?)(?:\n## |\n# |$)/);
|
|
3895
4000
|
if (!section) return [];
|
|
3896
4001
|
const checks = [];
|
|
@@ -3906,7 +4011,7 @@ function extractValidationChecks(taskId) {
|
|
|
3906
4011
|
function extractAcSummary(taskId) {
|
|
3907
4012
|
const specPath = path8.join(taskDirFor(taskId), "spec.md");
|
|
3908
4013
|
try {
|
|
3909
|
-
const content =
|
|
4014
|
+
const content = fs9.readFileSync(specPath, "utf8");
|
|
3910
4015
|
const lines = [];
|
|
3911
4016
|
for (const line of content.split("\n")) {
|
|
3912
4017
|
const match = line.match(/^-\s+\[[ x]\]\s+(AC-[\w.-]+):\s+(.+)$/);
|
|
@@ -3996,11 +4101,11 @@ ${acSection}`;
|
|
|
3996
4101
|
}
|
|
3997
4102
|
|
|
3998
4103
|
// src/orchestrator/review-archive.ts
|
|
3999
|
-
import
|
|
4104
|
+
import fs11 from "fs";
|
|
4000
4105
|
import path10 from "path";
|
|
4001
4106
|
|
|
4002
4107
|
// src/task/templates.ts
|
|
4003
|
-
import
|
|
4108
|
+
import fs10 from "fs";
|
|
4004
4109
|
import path9 from "path";
|
|
4005
4110
|
function tasksRoot() {
|
|
4006
4111
|
return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
|
|
@@ -4013,17 +4118,17 @@ function taskTemplateOverrideRoot() {
|
|
|
4013
4118
|
}
|
|
4014
4119
|
function resolveTaskTemplateSource(basename) {
|
|
4015
4120
|
const override = path9.join(taskTemplateOverrideRoot(), basename);
|
|
4016
|
-
if (
|
|
4121
|
+
if (fs10.existsSync(override)) return override;
|
|
4017
4122
|
const managed = path9.join(templatesRoot(), basename);
|
|
4018
|
-
return
|
|
4123
|
+
return fs10.existsSync(managed) ? managed : null;
|
|
4019
4124
|
}
|
|
4020
4125
|
function renderTaskTemplate(source, taskId, title) {
|
|
4021
|
-
return
|
|
4126
|
+
return fs10.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
|
|
4022
4127
|
}
|
|
4023
4128
|
function scaffoldTaskArtifact(taskDir, basename, taskId, title) {
|
|
4024
4129
|
const source = resolveTaskTemplateSource(basename);
|
|
4025
4130
|
if (!source) return null;
|
|
4026
|
-
|
|
4131
|
+
fs10.writeFileSync(path9.join(taskDir, basename), renderTaskTemplate(source, taskId, title), "utf8");
|
|
4027
4132
|
return source;
|
|
4028
4133
|
}
|
|
4029
4134
|
function isPristineTaskArtifact(content, basename, taskId, title) {
|
|
@@ -4039,10 +4144,79 @@ function isPristineTaskArtifact(content, basename, taskId, title) {
|
|
|
4039
4144
|
|
|
4040
4145
|
// src/orchestrator/review-archive.ts
|
|
4041
4146
|
var REVIEW_ARCHIVE_PREFIX = "review-prior-";
|
|
4147
|
+
var COLD_CODEX_ARCHIVE_PREFIX = "review-cold-codex-run-";
|
|
4148
|
+
var COLD_CODEX_ARCHIVE_RE = /^review-cold-codex-run-(\d+)\.md$/;
|
|
4149
|
+
function writeColdCodexArchive(taskDir, header, findings) {
|
|
4150
|
+
let newest = 0;
|
|
4151
|
+
for (const name2 of fs11.readdirSync(taskDir)) {
|
|
4152
|
+
const match = COLD_CODEX_ARCHIVE_RE.exec(name2);
|
|
4153
|
+
if (match) newest = Math.max(newest, Number(match[1]));
|
|
4154
|
+
}
|
|
4155
|
+
const name = `${COLD_CODEX_ARCHIVE_PREFIX}${newest + 1}.md`;
|
|
4156
|
+
const reason = header.reason.replaceAll('"', "'");
|
|
4157
|
+
fs11.writeFileSync(
|
|
4158
|
+
path10.join(taskDir, name),
|
|
4159
|
+
`<!-- round=${header.round} reviewed_sha=${header.reviewedSha} scope=${header.scope} base=${header.base} reason="${reason}" -->
|
|
4160
|
+
|
|
4161
|
+
${findings}`,
|
|
4162
|
+
"utf8"
|
|
4163
|
+
);
|
|
4164
|
+
return name;
|
|
4165
|
+
}
|
|
4166
|
+
function parseColdCodexArchiveHeader(content) {
|
|
4167
|
+
const match = /^<!-- round=(\d+) reviewed_sha=([a-f\d]{40,64}) scope=(full|delta) base=(\S+) reason="([^"\n]*)" -->\r?\n\r?\n/.exec(content);
|
|
4168
|
+
if (!match) return null;
|
|
4169
|
+
return {
|
|
4170
|
+
round: Number(match[1]),
|
|
4171
|
+
reviewedSha: match[2],
|
|
4172
|
+
scope: match[3],
|
|
4173
|
+
base: match[4],
|
|
4174
|
+
reason: match[5]
|
|
4175
|
+
};
|
|
4176
|
+
}
|
|
4177
|
+
function findColdCodexArchiveForRound(taskDir, round) {
|
|
4178
|
+
let names;
|
|
4179
|
+
try {
|
|
4180
|
+
names = fs11.readdirSync(taskDir);
|
|
4181
|
+
} catch {
|
|
4182
|
+
return null;
|
|
4183
|
+
}
|
|
4184
|
+
const numbered = names.flatMap((name) => {
|
|
4185
|
+
const match = COLD_CODEX_ARCHIVE_RE.exec(name);
|
|
4186
|
+
return match ? [{ name, number: Number(match[1]) }] : [];
|
|
4187
|
+
}).sort((a, b) => b.number - a.number);
|
|
4188
|
+
for (const { name } of numbered) {
|
|
4189
|
+
let content;
|
|
4190
|
+
try {
|
|
4191
|
+
content = fs11.readFileSync(path10.join(taskDir, name), "utf8");
|
|
4192
|
+
} catch {
|
|
4193
|
+
continue;
|
|
4194
|
+
}
|
|
4195
|
+
const header = parseColdCodexArchiveHeader(content);
|
|
4196
|
+
if (header?.round === round) return header;
|
|
4197
|
+
}
|
|
4198
|
+
return null;
|
|
4199
|
+
}
|
|
4200
|
+
function hasMalformedColdCodexArchive(taskDir) {
|
|
4201
|
+
let names;
|
|
4202
|
+
try {
|
|
4203
|
+
names = fs11.readdirSync(taskDir);
|
|
4204
|
+
} catch {
|
|
4205
|
+
return false;
|
|
4206
|
+
}
|
|
4207
|
+
return names.some((name) => {
|
|
4208
|
+
if (!COLD_CODEX_ARCHIVE_RE.test(name)) return false;
|
|
4209
|
+
try {
|
|
4210
|
+
return parseColdCodexArchiveHeader(fs11.readFileSync(path10.join(taskDir, name), "utf8")) === null;
|
|
4211
|
+
} catch {
|
|
4212
|
+
return true;
|
|
4213
|
+
}
|
|
4214
|
+
});
|
|
4215
|
+
}
|
|
4042
4216
|
var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
|
|
4043
4217
|
function newestReviewArchiveNumber(taskDir) {
|
|
4044
4218
|
let newest = 0;
|
|
4045
|
-
for (const name of
|
|
4219
|
+
for (const name of fs11.readdirSync(taskDir)) {
|
|
4046
4220
|
const match = REVIEW_ARCHIVE_RE.exec(name);
|
|
4047
4221
|
if (match) newest = Math.max(newest, Number(match[1]));
|
|
4048
4222
|
}
|
|
@@ -4054,21 +4228,21 @@ function findNewestReviewArchive(taskDir) {
|
|
|
4054
4228
|
}
|
|
4055
4229
|
function archivePriorReview(taskDir, options = {}) {
|
|
4056
4230
|
const reviewPath = path10.join(taskDir, "review.md");
|
|
4057
|
-
if (!
|
|
4231
|
+
if (!fs11.existsSync(reviewPath)) return null;
|
|
4058
4232
|
if (options.skipUnfilledTemplate) {
|
|
4059
|
-
const content =
|
|
4233
|
+
const content = fs11.readFileSync(reviewPath, "utf8");
|
|
4060
4234
|
if (isTemplateUnfilled(content)) return null;
|
|
4061
4235
|
if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
|
|
4062
4236
|
return null;
|
|
4063
4237
|
}
|
|
4064
4238
|
}
|
|
4065
4239
|
const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
|
|
4066
|
-
|
|
4240
|
+
fs11.renameSync(reviewPath, path10.join(taskDir, archiveName));
|
|
4067
4241
|
return archiveName;
|
|
4068
4242
|
}
|
|
4069
4243
|
function rescaffoldReview(taskDir, identity) {
|
|
4070
4244
|
try {
|
|
4071
|
-
if (
|
|
4245
|
+
if (fs11.existsSync(path10.join(taskDir, "review.md"))) return { outcome: "exists" };
|
|
4072
4246
|
const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
|
|
4073
4247
|
return source ? { outcome: "written", source } : { outcome: "no-template" };
|
|
4074
4248
|
} catch (error) {
|
|
@@ -4542,10 +4716,10 @@ function renderTemplate(template, view) {
|
|
|
4542
4716
|
}
|
|
4543
4717
|
|
|
4544
4718
|
// src/orchestrator/prompts/templates/code-review-foreman.md
|
|
4545
|
-
var code_review_foreman_default = "You are the synthesis foreman for the code review phase for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\n## Code-Review Rules of Thumb (Foreman)\n\n- **Reviewer diffs against the task baseline, not `main`, on release branches**: on a shared release branch ahead of `main`, always diff against the task's baseline \u2014 diffing against `main` attributes unrelated work to the task.\n- **Use `git -C <absolute-path>` for every worktree git op, not `cd` + git**: when operating across REPO_ROOT and a task worktree, `git -C /absolute/path` avoids silent cwd reversion between tool calls.\n- **Don't infer one git invariant from another**: `git status --porcelain` empty \u2260 origin matches HEAD; `origin/<branch>` exists \u2260 origin matches HEAD; PR exists \u2260 PR is in the expected state. Do the actual check directly.\n- **A cross-cutting invariant belongs in one shared helper, not patched per call site**: when the same rule must hold at multiple enforcement points, implement it once. The tell: findings come back round after round as the same bug class at a new location. At \u22653 sites, extract the shared helper and route all sites through it.\n\nYour job is to synthesize three review inputs: the anchored Claude lens, the cold-Claude lens, and the pre-obtained cold-Codex findings injected below. You spawn the Claude lenses as isolated sub-agents, collect their findings, adjudicate all three inputs using the spec (which you hold and the cold lenses do not), then write one `review.md` and set the verdict. Do not run `codex` yourself.\n\nTasks:\n{{{taskLines}}}\n\n{{#isRound1}}\nThis is Round 1, the initial code review.\n{{/isRound1}}\n{{^isRound1}}\nThis is Round {{roundN}}: re-review after iteration {{priorIteration}}. The lenses re-run from scratch. Direct the anchored lens to read the Iteration {{priorIteration}} section of `handoff.md` that addresses review round {{priorIteration}}.\n{{#tightenLine}}\n{{{tightenLine}}}\n{{/tightenLine}}\n{{/isRound1}}\n\n{{#hasDiff}}\nTask diff against {{{baseBranch}}}:\n\n```diff\n{{{diffContent}}}\n```\n{{#diffTruncated}}\n> Diff truncated at 50 000 bytes. Give the Claude lenses the visible diff first; for the omitted remainder, direct them to inspect only the changed files named in the handoff Changes table. Do not give the cold-Claude lens spec, AC, or canon-doc context.\n{{/diffTruncated}}\n{{/hasDiff}}\n{{^hasDiff}}\nRetrieve the task diff with `git diff {{{baseBranch}}}...HEAD`.\n{{/hasDiff}}\n\n## Injected Cold-Codex Findings\n\n{{#hasColdCodexFindings}}\nThe orchestrator ran `codex review` over the task's branch diff before spawning you. Its findings are reproduced below. These are unanchored: Codex reviewed adversarially without the spec as a checklist. Treat them as the third lens input. Do not re-run Codex; synthesize these findings alongside the Claude lens outputs.\n\n{{{coldCodexFindings}}}\n{{/hasColdCodexFindings}}\n{{^hasColdCodexFindings}}\nNo cold-Codex findings were provided to this prompt. In production code_review, the orchestrator must obtain that artifact before foreman synthesis; do not treat a missing cold-Codex lens as approval evidence.\n{{/hasColdCodexFindings}}\n\n## Foreman Protocol\n\n### 1. Spawn Claude Lenses In Parallel\n\nSpawn both Claude lenses with the sub-agent tool (`Agent`, called `Task` in older harnesses) in a single message so they run concurrently, and run them **in the foreground** (`run_in_background: false`) so the call returns their findings. Do not spawn them in the background and do not end your turn to wait for them: a turn that ends while a lens is still running ends the code review with no `review.md` and no verdict, and the phase is retried from scratch. Your turn ends only after step 5 below has run.\n\n**Anchored lens** (`subagent_type: code-review-anchored`)\n- Give it the full diff, `spec.md`, `handoff.md`, and prior `review.md` if this is a re-review.\n- It applies canon's anchored Stage 1 / Stage 2 code-review charter.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\n**Cold-Claude lens** (`subagent_type: code-review-cold`)\n- Give it the full diff and base ref only.\n- Do not give it `spec.md`, ACs, handoff rationale, canon docs, known risks, or your anchored-lens prompt.\n- If it needs to inspect files for truncated diff context, constrain it to changed files only and preserve the spec-blind framing.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\nThe injected cold-Codex findings above are the third lens input. Do not spawn a Codex agent or shell out to Codex yourself. Do not let a Claude lens see another lens's output.\n\n### 2. Adjudicate\n\nUse the three lens inputs and the spec. Do not perform a new full diff review for novel bugs; your role is synthesis and adjudication.\n\nThe lenses are instructed to over-report \u2014 to surface low-confidence and low-severity findings rather than self-censor. Filtering is **your** job, not theirs: a quiet lens output is a bug in the lens, not a clean diff. Rank surviving findings by confidence \xD7 severity. A low-confidence, low-severity finding is a nit or gets dismissed; it does not by itself drive `changes_requested`. Do not discard a finding merely because a lens marked it low-confidence \u2014 verify it against the spec/diff first, then rank.\n\n1. Dedup: if 2+ lenses flagged the same behavior, collapse it to one finding and record \"flagged by N lenses.\" A finding flagged by 2+ lenses is higher-confidence regardless of any lens's self-tag. Cross-model agreement \u2014 the same behavior flagged by cold-Claude and cold-Codex \u2014 must not be dismissed as spec-intended without explicit spec evidence cited in `review.md`.\n2. Keep the two reconciliation checks separate:\n - Does it hold against the code? For cold findings (cold-Claude and cold-Codex), verify each against the diff/code. Codex P-levels are claims to check, not verdicts. A finding that does not hold gets recorded as `Dismissed (cold-Claude): <finding> - <reason>` or `Dismissed (cold-Codex): <finding> - <reason>`.\n - Is it in spec scope? Apply this only to anchored-lens findings as part of the Stage 1 / Stage 2 charter.\n - Forbidden: do not dismiss a verified cold-Claude or cold-Codex finding merely for being off-AC or out of spec scope. A real bug caught by a cold lens is still a bug even if no AC named it.\n3. Altitude classification: every surviving finding is either:\n - `code-bug`: the implementation is wrong or test integrity is compromised.\n - `spec-gap`: the implementation may match the written spec, but the spec is missing, wrong, or too ambiguous for the implementer to fix.\n\n### 3. Choose Verdict\n\n- Any `code-bug` finding -> `changes_requested`.\n- Any `spec-gap` finding and no code-bugs -> `spec_gap`.\n- Optional nits or cleanup without blocking findings -> `approved_with_nits`.\n- No surviving findings -> `approved`.\n\nTest-integrity findings are always code-bugs.\n\n### 4. Write `review.md`\n\nFor each task, write `tasks/<id>/review.md`.\n\nRound 1 fills the existing template structure directly \u2014 do **not** wrap it in a `## Round 1` section; the `## Stage 1` and `## Stage 2` headings stay at H2. Re-review appends a new `## Round {{roundN}}` section near the bottom (with `### Stage 1` / `### Stage 2` sub-headings), preserving earlier rounds.\n\nInclude:\n- Stage 1: anchored lens validation gate result and AC table.\n- Stage 2 / Findings: surviving findings with altitude (`code-bug` or `spec-gap`), source lens, and file:line.\n- Dismissed Cold Findings: every dropped cold finding plus the reason, including `Dismissed (cold-Claude): ...` and `Dismissed (cold-Codex): ...` entries where applicable.\n- Final Verdict: check exactly one verdict checkbox, including `Spec gap` when applicable.\n\n### 5. Set Phase Verdict\n\nRun one command per task with the actual verdict:\n{{{phaseCommands}}}\n";
|
|
4719
|
+
var code_review_foreman_default = "You are the synthesis foreman for the code review phase for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\n## Code-Review Rules of Thumb (Foreman)\n\n- **Reviewer diffs against the task baseline, not `main`, on release branches**: on a shared release branch ahead of `main`, always diff against the task's baseline \u2014 diffing against `main` attributes unrelated work to the task.\n- **Use `git -C <absolute-path>` for every worktree git op, not `cd` + git**: when operating across REPO_ROOT and a task worktree, `git -C /absolute/path` avoids silent cwd reversion between tool calls.\n- **Don't infer one git invariant from another**: `git status --porcelain` empty \u2260 origin matches HEAD; `origin/<branch>` exists \u2260 origin matches HEAD; PR exists \u2260 PR is in the expected state. Do the actual check directly.\n- **A cross-cutting invariant belongs in one shared helper, not patched per call site**: when the same rule must hold at multiple enforcement points, implement it once. The tell: findings come back round after round as the same bug class at a new location. At \u22653 sites, extract the shared helper and route all sites through it.\n\nYour job is to synthesize three review inputs: the anchored Claude lens, the cold-Claude lens, and the pre-obtained cold-Codex findings injected below. You spawn the Claude lenses as isolated sub-agents, collect their findings, adjudicate all three inputs using the spec (which you hold and the cold lenses do not), then write one `review.md` and set the verdict. Do not run `codex` yourself.\n\nTasks:\n{{{taskLines}}}\n\n{{#isRound1}}\nThis is Round 1, the initial code review.\n{{/isRound1}}\n{{^isRound1}}\nThis is Round {{roundN}}: re-review after iteration {{priorIteration}}. All three lenses run again this round. Direct the anchored lens to read the Iteration {{priorIteration}} section of `handoff.md` that addresses review round {{priorIteration}}.\n{{#tightenLine}}\n{{{tightenLine}}}\n{{/tightenLine}}\n{{/isRound1}}\n\n**Scope:** {{{scopeWord}}} \u2014 base `{{{scopeBase}}}` \u2014 reason: {{{scopeReason}}}\n\n{{#hasDiff}}\nTask diff against {{{baseBranch}}}:\n\n```diff\n{{{diffContent}}}\n```\n{{#diffTruncated}}\n> Diff truncated at 50 000 bytes. Give the Claude lenses the visible diff first; for the omitted remainder, direct them to inspect only the changed files named in the handoff Changes table. Do not give the cold-Claude lens spec, AC, or canon-doc context.\n{{/diffTruncated}}\n{{/hasDiff}}\n{{^hasDiff}}\nRetrieve the task diff with `git diff {{{baseBranch}}}...HEAD`.\n{{/hasDiff}}\n{{#isDeltaScope}}\n\n{{#hasDeltaDiff}}\nDelta diff (`{{{deltaBase}}}..HEAD`) \u2014 give this delta to the cold-Claude lens:\n\n```diff\n{{{deltaDiffContent}}}\n```\n{{#deltaDiffTruncated}}\n> Delta diff truncated at 50 000 bytes. Give the cold-Claude lens the visible delta first; for the remainder, direct it to `git diff {{{deltaBase}}}..HEAD`.\n{{/deltaDiffTruncated}}\n{{/hasDeltaDiff}}\n{{^hasDeltaDiff}}\nThe delta diff could not be injected. Retrieve it with `git diff {{{deltaBase}}}..HEAD` and give it to the cold-Claude lens before it reviews.\n{{/hasDeltaDiff}}\n{{/isDeltaScope}}\n\n## Injected Cold-Codex Findings\n\n{{#hasColdCodexFindings}}\nThe orchestrator ran `codex review` before spawning you. Its findings are reproduced below. These are unanchored: Codex reviewed adversarially without the spec as a checklist. Treat them as the third lens input. Do not re-run Codex; synthesize these findings alongside the Claude lens outputs.\n{{#isDeltaScope}}\nFor this round, cold-Codex reviewed only `{{{deltaBase}}}..HEAD`.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\nFor this round, cold-Codex reviewed the full task diff against `{{{baseBranch}}}`.\n{{/isDeltaScope}}\n\n{{{coldCodexFindings}}}\n{{/hasColdCodexFindings}}\n{{^hasColdCodexFindings}}\nNo cold-Codex findings were provided to this prompt. In production code_review, the orchestrator must obtain that artifact before foreman synthesis; do not treat a missing cold-Codex lens as approval evidence.\n{{/hasColdCodexFindings}}\n\n{{#hasOutOfScopeFiles}}\n## Full-Send Scope Judgment\n\nThe task changed these files outside the spec's `### Affected Files` table:\n\n{{{outOfScopeFilesList}}}\n\nJudge each file against the owning task's ACs and record its outcome and reason in `review.md`:\n\n1. **Appropriate scope expansion:** If needed to meet the ACs and consistent with the spec's intent, append an `## Amendment (full-send scope)` section to the owning task's `spec.md` (use exactly that heading; a bare `## Amendment` is reserved for human reroute amendments). Include an exact `### Affected Files` heading and table row naming the file, plus a one-line reason that calls it a full-send scope expansion.\n2. **Spec miss:** If the spec should have listed the file, append the same `## Amendment (full-send scope)` section and exact `### Affected Files` table row, with a one-line reason that calls it a spec miss.\n3. **Should not have been changed:** Do not amend the spec. Write a finding requiring the file to be reverted or removed, and set the verdict to `changes_requested`.\n\nFor a bundle, amend the member spec whose ACs the file serves. The orchestrator checks after code review completes: every listed file must be in a member's Affected Files or a `changes_requested` verdict must route it back to implement. The legacy `needs_re_review` verdict routes the same way. A `spec_gap` verdict blocks for human triage and names any files still outside Affected Files; blessing that verdict does not amend the spec. An approved review with unjudged files auto-blocks.\n\n{{/hasOutOfScopeFiles}}\n## Foreman Protocol\n\n### 1. Spawn Claude Lenses In Parallel\n\nSpawn both Claude lenses with the sub-agent tool (`Agent`, called `Task` in older harnesses) in a single message so they run concurrently, and run them **in the foreground** (`run_in_background: false`) so the call returns their findings. Do not spawn them in the background and do not end your turn to wait for them: a turn that ends while a lens is still running ends the code review with no `review.md` and no verdict, and the phase is retried from scratch. Your turn ends only after step 5 below has run.\n\n**Anchored lens** (`subagent_type: code-review-anchored`)\n{{#isDeltaScope}}\n- Retrieve the full task diff with `git diff {{{baseBranch}}}...HEAD` and give it that full diff, not the delta, plus `spec.md`, `handoff.md`, and prior `review.md`.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\n- Give it the full diff, `spec.md`, `handoff.md`, and prior `review.md` if this is a re-review.\n{{/isDeltaScope}}\n- It applies canon's anchored Stage 1 / Stage 2 code-review charter.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\n**Cold-Claude lens** (`subagent_type: code-review-cold`)\n{{#isDeltaScope}}\n- This round is delta-scoped. Give it the delta (`{{{deltaBase}}}..HEAD`) and the delta base ref only.\n{{/isDeltaScope}}\n{{^isDeltaScope}}\n- Give it the full diff and base ref only.\n{{/isDeltaScope}}\n- Do not give it `spec.md`, ACs, handoff rationale, canon docs, known risks, or your anchored-lens prompt.\n- If it needs to inspect files for truncated diff context, constrain it to changed files only and preserve the spec-blind framing.\n- Separately, its sibling-site sweep may search other code files for guards, checks, and invariants that need the same treatment. Keep the search spec-blind.\n- It returns structured findings to you. It must not write `review.md` or run `canon task phase`.\n\nThe injected cold-Codex findings above are the third lens input. Do not spawn a Codex agent or shell out to Codex yourself. Do not let a Claude lens see another lens's output.\n\n### 2. Adjudicate\n\nUse the three lens inputs and the spec. Do not perform a new full diff review for novel bugs; your role is synthesis and adjudication.\n\nThe lenses are instructed to over-report \u2014 to surface low-confidence and low-severity findings rather than self-censor. Filtering is **your** job, not theirs: a quiet lens output is a bug in the lens, not a clean diff. Rank surviving findings by confidence \xD7 severity. A low-confidence, low-severity finding is a nit or gets dismissed; it does not by itself drive `changes_requested`. Do not discard a finding merely because a lens marked it low-confidence \u2014 verify it against the spec/diff first, then rank.\n\n1. Dedup: if 2+ lenses flagged the same behavior, collapse it to one finding and record \"flagged by N lenses.\" A finding flagged by 2+ lenses is higher-confidence regardless of any lens's self-tag. Cross-model agreement \u2014 the same behavior flagged by cold-Claude and cold-Codex \u2014 must not be dismissed as spec-intended without explicit spec evidence cited in `review.md`.\n2. Keep the two reconciliation checks separate:\n - Does it hold against the code? For cold findings (cold-Claude and cold-Codex), verify each against the diff/code. Codex P-levels are claims to check, not verdicts. A finding that does not hold gets recorded as `Dismissed (cold-Claude): <finding> - <reason>` or `Dismissed (cold-Codex): <finding> - <reason>`.\n - Is it in spec scope? Apply this only to anchored-lens findings as part of the Stage 1 / Stage 2 charter.\n - Forbidden: do not dismiss a verified cold-Claude or cold-Codex finding merely for being off-AC or out of spec scope. A real bug caught by a cold lens is still a bug even if no AC named it.\n3. Altitude classification: every surviving finding is either:\n - `code-bug`: the implementation is wrong or test integrity is compromised.\n - `spec-gap`: the implementation may match the written spec, but the spec is missing, wrong, or too ambiguous for the implementer to fix.\n\n### 3. Choose Verdict\n\n- Any `code-bug` finding -> `changes_requested`.\n- Any `spec-gap` finding and no code-bugs -> `spec_gap`.\n- Optional nits or cleanup without blocking findings -> `approved_with_nits`.\n- No surviving findings -> `approved`.\n\nTest-integrity findings are always code-bugs.\n\n### 4. Write `review.md`\n\nFor each task, write `tasks/<id>/review.md`.\n\nRound 1 fills the existing template structure directly \u2014 do **not** wrap it in a `## Round 1` section; the `## Stage 1` and `## Stage 2` headings stay at H2. Re-review appends a new `## Round {{roundN}}` section near the bottom (with `### Stage 1` / `### Stage 2` sub-headings), preserving earlier rounds.\n\nEvery round \u2014 the Round 1 body and each `## Round N` section \u2014 includes one scope line near the top: **Scope:** {{{scopeWord}}} \u2014 base `{{{scopeBase}}}` \u2014 reason: {{{scopeReason}}}. The numbered cold-Codex archive header is the machine-readable record.\n\nInclude:\n- Stage 1: anchored lens validation gate result and AC table.\n- Stage 2 / Findings: surviving findings with altitude (`code-bug` or `spec-gap`), source lens, and file:line.\n- Dismissed Cold Findings: every dropped cold finding plus the reason, including `Dismissed (cold-Claude): ...` and `Dismissed (cold-Codex): ...` entries where applicable.\n- Final Verdict: check exactly one verdict checkbox, including `Spec gap` when applicable.\n\n### 5. Set Phase Verdict\n\nRun one command per task with the actual verdict:\n{{{phaseCommands}}}\n";
|
|
4546
4720
|
|
|
4547
4721
|
// src/orchestrator/prompts/templates/implement.md
|
|
4548
|
-
var implement_default = 'You are implementing {{taskScope}} for {{projectName}}.\n\n{{{stateHeader}}}\n{{{startup}}}\n{{{risksBlock}}}{{{pitfallsBlock}}}{{{contextBlock}}}\n{{{affectedFilesBlock}}}\nTasks to implement:\n{{{taskLines}}}{{#isBundle}}\nThese tasks are related \u2014 implement them together. Consider shared code paths and cross-task interactions.{{/isBundle}}\n\nGrounding rule: before you write handoff.md, re-open the files you changed and verify the current diff against the spec. Do not treat a previous session\'s memory as proof that the work is already in place.\n\n**Spec ACs are binding. Plan approach is guidance.**\n- Every Acceptance Criterion in spec.md MUST be met \u2014 these are non-negotiable.\n- If you find a better implementation approach than what\'s in the plan, use it. Document every deviation in handoff.md under "Deviations" with specific rationale.\n- You may NOT silently drop an AC, skip a required validation check, or omit a spec requirement.\n- If an AC is infeasible as written, document it in Blockers \u2014 do not silently skip.\n- If an AC is ambiguous enough that two reasonable implementations exist, document your interpretation in handoff.md under Blockers with label `[ambiguity]` \u2014 do not silently guess. Claude will evaluate whether the interpretation was correct.\n\n## Implementation Rules\n\n**Safe-First Rules** \u2014 always applicable regardless of stack:\n1. For storage, reload, sync, or data-affecting flows: ship the safer guarded behavior first.\n2. Behavior that reloads the app, replaces local state, or dismisses user work must be gated by explicit user action.\n3. Prefer shared types over duplicating signatures.\n\n**Scope Discipline** \u2014 always applicable; the spec is the contract:\n1. **Affected Files is the scope cap.** If satisfying an AC genuinely requires editing files outside the spec\'s *Affected Files* table, do not make that edit. Record the gap in `handoff.md` under *Blockers* \u2014 the handoff is how it reaches a human \u2014 then finish the remaining in-scope work, the handoff, and the phase command. Do not silently expand scope.\n2. **No unauthorized new abstractions.** Do not introduce new top-level modules, services, packages, or routing layers that the spec did not authorize. Minor refactors within an authorized file are fine; new abstractions are an architecture decision and belong in the spec.\n3. **No incidental dependency changes.** Do not add, remove, upgrade, or downgrade dependencies (or their pinned versions) unless the spec explicitly requests it.\n\n**Lint & Type Safety Policy** \u2014 always applicable:\n1. **Suppressing a lint or type error is a last resort**, not a convenience escape hatch. Never add a suppression without a same-line justification explaining *why the rule is wrong for this specific case*.\n2. **`any` / dynamic typing**: When the shape is truly unknown at the boundary, type as `unknown` and narrow explicitly.\n\n**Bug/Flake-Fix Red-First Checkpoint** \u2014 applicable when a spec\'s ACs include a red-first regression test:\nWrite the test and run it against the pre-fix code first; confirm it fails *for the reason the spec states*, then apply the fix and confirm it passes. Report the red run (command + observed failure) in handoff.md. If the test cannot be made to fail on the pre-fix code for the stated reason, do not implement a fix on a premise you could not reproduce \u2014 record a `[wrong-premise]` Blocker in handoff.md instead, then finish the remaining in-scope work, the handoff, and the phase command. If the spec instead uses the environment-bound-and-impractical escape, run its named deterministic alternative before fixing if it is executable in your sandbox (e.g. an integration fixture) and report the outcome in handoff.md the same way; if it is not executable (e.g. a documented manual repro), state that in handoff.md instead \u2014 never report an outcome for a run that did not happen.\n\n**Parsing Structured Input** \u2014 always applicable when implementing a parser for author-facing structured input:\nParse cell-by-cell with explicit rejection, not a permissive whole-string regex. Anchor each cell to exactly one expected shape and reject malformed cells with a specific reason at the parse boundary.\n\nRun ALL applicable validation checks before writing handoff. See "Validation Required" in each spec.md. The universal change-type \u2192 check-category matrix:\n\n| Change Type | Required Check Categories |\n|---|---|\n| Most changes | Linting, type checking, unit tests |\n| Docs references | Docs references |\n| Routes / config / build | Full build |\n| UI / interaction changes | End-to-end tests |\n| Content / SEO / metadata | Prerender / sitemap / feed regeneration |\n| Schema / migration | Migration runner + manual review |\n| Cross-platform | Subset of the above on each platform |\n\nFor which command runs each category: see `docs/architecture.md` \xA7Validation (project command bindings). Required checks must be recorded as Pass or Fail; do not mark a required check N/A unless the spec explicitly removed it.\n\n**Test flakiness in your sandbox.** Validation suites \u2014 especially E2E or integration tests \u2014 can hit transient failures (timing races, environment quirks, network jitter) that have nothing to do with the code in your spec\'s Affected Files. **If a failure is in a test / file outside your Affected Files table, do NOT fix it.** Note the observed test name, file, line, and a one-line repro hint in handoff.md \u2192 Blockers (or "Validation Outcomes" Notes column with status `Fail \u2013 unrelated`), then continue. `Fail \u2013 unrelated` is only valid for failures in files outside your Affected Files; a failure in a file you changed is yours to fix. Scope discipline > fixing adjacent bugs you spot during validation. The reviewer/operator will decide whether to triage the unrelated failure separately.\n\nFor each task, write tasks/<id>/handoff.md using the template. The Validation Outcomes table must have no Fail results EXCEPT for unrelated-flake rows clearly labeled in the Notes column.\nAppend to tasks/<id>/notes.md for any surprising codebase behavior (prefix: [implement]).\n\nWhen done, run:\n{{{phaseCommands}}}\n';
|
|
4722
|
+
var implement_default = 'You are implementing {{taskScope}} for {{projectName}}.\n\n{{{stateHeader}}}\n{{{startup}}}\n{{{risksBlock}}}{{{pitfallsBlock}}}{{{contextBlock}}}\n{{{affectedFilesBlock}}}\nTasks to implement:\n{{{taskLines}}}{{#isBundle}}\nThese tasks are related \u2014 implement them together. Consider shared code paths and cross-task interactions.{{/isBundle}}\n\nGrounding rule: before you write handoff.md, re-open the files you changed and verify the current diff against the spec. Do not treat a previous session\'s memory as proof that the work is already in place.\n\n**Spec ACs are binding. Plan approach is guidance.**\n- Every Acceptance Criterion in spec.md MUST be met \u2014 these are non-negotiable.\n- If you find a better implementation approach than what\'s in the plan, use it. Document every deviation in handoff.md under "Deviations" with specific rationale.\n- You may NOT silently drop an AC, skip a required validation check, or omit a spec requirement.\n- If an AC is infeasible as written, document it in Blockers \u2014 do not silently skip.\n- If an AC is ambiguous enough that two reasonable implementations exist, document your interpretation in handoff.md under Blockers with label `[ambiguity]` \u2014 do not silently guess. Claude will evaluate whether the interpretation was correct.\n\n## Implementation Rules\n\n**Safe-First Rules** \u2014 always applicable regardless of stack:\n1. For storage, reload, sync, or data-affecting flows: ship the safer guarded behavior first.\n2. Behavior that reloads the app, replaces local state, or dismisses user work must be gated by explicit user action.\n3. Prefer shared types over duplicating signatures.\n\n**Scope Discipline** \u2014 always applicable; the spec is the contract:\n1. **Affected Files is the scope cap.** If satisfying an AC genuinely requires editing files outside the spec\'s *Affected Files* table, do not make that edit. Record the gap in `handoff.md` under *Blockers* \u2014 the handoff is how it reaches a human \u2014 then finish the remaining in-scope work, the handoff, and the phase command. Do not silently expand scope. New helpers, fixtures, and test files introduced during implementation count against this cap too.\n2. **No unauthorized new abstractions.** Do not introduce new top-level modules, services, packages, or routing layers that the spec did not authorize. Minor refactors within an authorized file are fine; new abstractions are an architecture decision and belong in the spec.\n3. **No incidental dependency changes.** Do not add, remove, upgrade, or downgrade dependencies (or their pinned versions) unless the spec explicitly requests it.\n\n**Lint & Type Safety Policy** \u2014 always applicable:\n1. **Suppressing a lint or type error is a last resort**, not a convenience escape hatch. Never add a suppression without a same-line justification explaining *why the rule is wrong for this specific case*.\n2. **`any` / dynamic typing**: When the shape is truly unknown at the boundary, type as `unknown` and narrow explicitly.\n\n**Bug/Flake-Fix Red-First Checkpoint** \u2014 applicable when a spec\'s ACs include a red-first regression test:\nWrite the test and run it against the pre-fix code first; confirm it fails *for the reason the spec states*, then apply the fix and confirm it passes. Report the red run (command + observed failure) in handoff.md. If the test cannot be made to fail on the pre-fix code for the stated reason, do not implement a fix on a premise you could not reproduce \u2014 record a `[wrong-premise]` Blocker in handoff.md instead, then finish the remaining in-scope work, the handoff, and the phase command. If the spec instead uses the environment-bound-and-impractical escape, run its named deterministic alternative before fixing if it is executable in your sandbox (e.g. an integration fixture) and report the outcome in handoff.md the same way; if it is not executable (e.g. a documented manual repro), state that in handoff.md instead \u2014 never report an outcome for a run that did not happen.\n\n**Parsing Structured Input** \u2014 always applicable when implementing a parser for author-facing structured input:\nParse cell-by-cell with explicit rejection, not a permissive whole-string regex. Anchor each cell to exactly one expected shape and reject malformed cells with a specific reason at the parse boundary.\n\nRun ALL applicable validation checks before writing handoff. See "Validation Required" in each spec.md. The universal change-type \u2192 check-category matrix:\n\n| Change Type | Required Check Categories |\n|---|---|\n| Most changes | Linting, type checking, unit tests |\n| Docs references | Docs references |\n| Routes / config / build | Full build |\n| UI / interaction changes | End-to-end tests |\n| Content / SEO / metadata | Prerender / sitemap / feed regeneration |\n| Schema / migration | Migration runner + manual review |\n| Cross-platform | Subset of the above on each platform |\n\nFor which command runs each category: see `docs/architecture.md` \xA7Validation (project command bindings). Required checks must be recorded as Pass or Fail; do not mark a required check N/A unless the spec explicitly removed it.\n\n**Test flakiness in your sandbox.** Validation suites \u2014 especially E2E or integration tests \u2014 can hit transient failures (timing races, environment quirks, network jitter) that have nothing to do with the code in your spec\'s Affected Files. **If a failure is in a test / file outside your Affected Files table, do NOT fix it.** Note the observed test name, file, line, and a one-line repro hint in handoff.md \u2192 Blockers (or "Validation Outcomes" Notes column with status `Fail \u2013 unrelated`), then continue. `Fail \u2013 unrelated` is only valid for failures in files outside your Affected Files; a failure in a file you changed is yours to fix. Scope discipline > fixing adjacent bugs you spot during validation. The reviewer/operator will decide whether to triage the unrelated failure separately.\n\nFor each task, write tasks/<id>/handoff.md using the template. The Validation Outcomes table must have no Fail results EXCEPT for unrelated-flake rows clearly labeled in the Notes column.\nAppend to tasks/<id>/notes.md for any surprising codebase behavior (prefix: [implement]).\n\nWhen done, run:\n{{{phaseCommands}}}\n';
|
|
4549
4723
|
|
|
4550
4724
|
// src/orchestrator/prompts/templates/implement-reroute.md
|
|
4551
4725
|
var implement_reroute_default = "A human wrote a spec amendment and rerouted {{taskScope}} for {{projectName}}.\n\n{{{stateHeader}}}\n{{{roundBanner}}}{{{preamble}}}\n\n{{#startup}}{{{startup}}}\n{{/startup}}{{{risksBlock}}}{{{pitfallsBlock}}}{{{contextBlock}}}\n{{{affectedFilesBlock}}}\nTasks with amended specs:\n{{{taskLines}}}\n\n{{{groundingRule}}}\n\n**How to approach this:**\n0. If a task's line above marks it EXEMPT, skip steps 1-2 for that task entirely \u2014 its spec has NO Amendment section and its plan has NO Reroute Plan section, by design. For exempt tasks, follow the task-specific line above: approved siblings only need shared-behavior re-verification, while siblings with prior review findings must still address those findings.\n1. For each task above, read `tasks/<id>/spec.md` from your current working directory (the worktree). REPO_ROOT's copy is the pre-implement scaffold and does NOT contain operator amendments. Locate the exact heading named in its entry \u2014 `## Amendment` for round 1, or `## Amendment Round N` for round 2+. Each task carries its own reroute round (bundles may mix rounds), so use the heading specified in that task's line, not a bundle-wide assumption. Treat that section's content as the new requirements; ignore prior-round sections when implementing this one.\n2. Check `tasks/<id>/plan.md` for `## Reroute Plan` (round 1) or `## Reroute Plan Round N` (N = that task's reroute round). If present, use that section as the delta guide. If absent (fast-tier reroute with no conversational reroute plan), read the base plan for orientation.\n3. Read tasks/<id>/handoff.md to understand what you previously shipped. Do NOT assume the handoff covers the amendment \u2014 it was written before the amendment existed.\n4. Identify the delta: which ACs are new, which changed, which were already addressed by the previous implementation.\n5. Implement the delta. Previously-correct work stays; only change what the amendment requires. If the amendment conflicts with a prior AC, the amendment wins.\n6. Re-run ALL applicable validation checks (lint, type-check, test, build, e2e as applicable per the spec's Validation Required). Required checks must be recorded as Pass or Fail; do not mark a required check N/A.\n7. **Rewrite handoff.md** to reflect the complete current state of the implementation \u2014 including the round-1 work that still applies plus the new amendment work. The reviewer reads handoff.md as the single source of truth, not your prior session's context.\n\n**Spec ACs are binding** \u2014 including both original ACs and amendment ACs. If you think an amendment AC is infeasible as written, document it under Blockers in handoff.md. Do not silently drop any AC.\n\n**Bug/flake-fix red-first checkpoint** \u2014 if the amendment adds or modifies a red-first regression-test AC: write and run the test against the task's current state **before** making this round's fix edits, and confirm it fails *for the reason the spec states*; then apply the fix and confirm it passes. Report the red run in handoff.md. If it cannot be made to fail for that reason, record a `[wrong-premise]` Blocker instead of implementing on an unconfirmed premise. If the amendment instead relies on the environment-bound-and-impractical escape, run its named deterministic alternative if it is executable in your sandbox and report the outcome in handoff.md; if it is not executable (e.g. a documented manual repro), state that instead \u2014 never report an outcome for a run that did not happen.\n\nAppend to tasks/<id>/notes.md for any surprising behavior found while re-reading the codebase (prefix: `[implement-reroute]`).\n\nWhen done, run:\n{{{phaseCommands}}}\n";
|
|
@@ -4882,7 +5056,7 @@ function bundleHasRealPriorReview(taskIds) {
|
|
|
4882
5056
|
return taskIds.every((taskId) => {
|
|
4883
5057
|
const reviewPath = path11.join(taskDirFor(taskId), "review.md");
|
|
4884
5058
|
try {
|
|
4885
|
-
const content =
|
|
5059
|
+
const content = fs12.readFileSync(reviewPath, "utf8");
|
|
4886
5060
|
const hasH2 = /^## Stage 1\b/m.test(content);
|
|
4887
5061
|
const hasNested = /^## Round \d+\b/m.test(content) && /^### Stage 1\b/m.test(content);
|
|
4888
5062
|
return (hasH2 || hasNested) && !content.includes("[TASK-ID]");
|
|
@@ -4891,20 +5065,24 @@ function bundleHasRealPriorReview(taskIds) {
|
|
|
4891
5065
|
}
|
|
4892
5066
|
});
|
|
4893
5067
|
}
|
|
4894
|
-
function
|
|
4895
|
-
const { tasks } = state;
|
|
5068
|
+
function resolveCodeReviewRound(tasks) {
|
|
4896
5069
|
const rawMaxIter = tasks.reduce((max, t) => Math.max(max, t.iterations), 0);
|
|
4897
5070
|
const maxIter = bundleHasRealPriorReview(tasks.map((t) => t.taskId)) ? rawMaxIter : 0;
|
|
5071
|
+
return { isRound1: maxIter === 0, roundN: maxIter + 1, maxIter };
|
|
5072
|
+
}
|
|
5073
|
+
function promptCodeReview(state, baseBranch, scopedDiff = null, coldCodexFindings = null, scopeInfo = null, outOfScopeFiles = []) {
|
|
5074
|
+
const { tasks } = state;
|
|
5075
|
+
const { maxIter, isRound1, roundN } = resolveCodeReviewRound(tasks);
|
|
4898
5076
|
const resolvedBaseBranch = baseBranch ?? getBaseBranch(tasks.map((t) => t.taskId));
|
|
4899
|
-
const
|
|
4900
|
-
const
|
|
4901
|
-
const
|
|
5077
|
+
const isDeltaScope = scopeInfo?.scope === "delta";
|
|
5078
|
+
const effectiveScopedDiff = isDeltaScope ? null : scopedDiff;
|
|
5079
|
+
const hasDiff = effectiveScopedDiff !== null;
|
|
4902
5080
|
const priorIteration = maxIter;
|
|
4903
5081
|
const diffView = hasDiff ? {
|
|
4904
5082
|
hasDiff,
|
|
4905
5083
|
baseBranch: resolvedBaseBranch,
|
|
4906
|
-
diffContent:
|
|
4907
|
-
diffTruncated:
|
|
5084
|
+
diffContent: effectiveScopedDiff.diff,
|
|
5085
|
+
diffTruncated: effectiveScopedDiff.truncated
|
|
4908
5086
|
} : {
|
|
4909
5087
|
hasDiff,
|
|
4910
5088
|
baseBranch: resolvedBaseBranch,
|
|
@@ -4929,8 +5107,18 @@ function promptCodeReview(state, baseBranch, scopedDiff = null, coldCodexFinding
|
|
|
4929
5107
|
maxIter,
|
|
4930
5108
|
tightenLine,
|
|
4931
5109
|
...diffView,
|
|
5110
|
+
isDeltaScope,
|
|
5111
|
+
scopeWord: isDeltaScope ? "Delta" : "Full",
|
|
5112
|
+
scopeBase: scopeInfo?.base ?? resolvedBaseBranch,
|
|
5113
|
+
scopeReason: scopeInfo?.reason === "delta" ? "small fix within the files already under review" : scopeInfo?.reason ?? (isRound1 ? "Round 1 (initial review)" : "full review"),
|
|
5114
|
+
deltaBase: scopeInfo?.base ?? "",
|
|
5115
|
+
deltaDiffContent: scopeInfo?.deltaDiff?.diff ?? "",
|
|
5116
|
+
hasDeltaDiff: scopeInfo?.deltaDiff !== null && scopeInfo?.deltaDiff !== void 0,
|
|
5117
|
+
deltaDiffTruncated: scopeInfo?.deltaDiff?.truncated ?? false,
|
|
4932
5118
|
coldCodexFindings: coldCodexFindings ?? "",
|
|
4933
5119
|
hasColdCodexFindings: coldCodexFindings !== null,
|
|
5120
|
+
hasOutOfScopeFiles: outOfScopeFiles.length > 0,
|
|
5121
|
+
outOfScopeFilesList: outOfScopeFiles.map((file) => `- \`${file}\``).join("\n"),
|
|
4934
5122
|
phaseCommands: phaseCommands(tasks.map((t) => t.taskId), "code_review", "done", "<verdict>")
|
|
4935
5123
|
});
|
|
4936
5124
|
}
|
|
@@ -4953,19 +5141,19 @@ function promptQa(state, prTemplate) {
|
|
|
4953
5141
|
|
|
4954
5142
|
// src/task/index.ts
|
|
4955
5143
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
4956
|
-
import
|
|
5144
|
+
import fs15 from "fs";
|
|
4957
5145
|
import path14 from "path";
|
|
4958
5146
|
|
|
4959
5147
|
// src/orchestrator/canon-snapshot.ts
|
|
4960
5148
|
import { spawnSync as spawnSync4 } from "child_process";
|
|
4961
|
-
import
|
|
5149
|
+
import fs13 from "fs";
|
|
4962
5150
|
import path12 from "path";
|
|
4963
5151
|
var CANON_UPSTREAM_REPO = "tstraub89/canon-ai";
|
|
4964
5152
|
function isInstalledSourcePath(sourcePath) {
|
|
4965
5153
|
return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
|
|
4966
5154
|
}
|
|
4967
5155
|
function resolveCanonVersion(explicit) {
|
|
4968
|
-
return explicit ?? "3.
|
|
5156
|
+
return explicit ?? "3.4.0" ?? "dev";
|
|
4969
5157
|
}
|
|
4970
5158
|
function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
|
|
4971
5159
|
const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
|
|
@@ -5044,14 +5232,14 @@ function applyCanonSnapshot(status, canon) {
|
|
|
5044
5232
|
return next;
|
|
5045
5233
|
}
|
|
5046
5234
|
function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
|
|
5047
|
-
const status = JSON.parse(
|
|
5235
|
+
const status = JSON.parse(fs13.readFileSync(statusFilePath, "utf8"));
|
|
5048
5236
|
const canon = captureCanonSnapshot(REPO_ROOT, options);
|
|
5049
5237
|
const next = applyCanonSnapshot(status, canon);
|
|
5050
5238
|
const serialized = `${JSON.stringify(next, null, 2)}
|
|
5051
5239
|
`;
|
|
5052
|
-
const current =
|
|
5240
|
+
const current = fs13.readFileSync(statusFilePath, "utf8");
|
|
5053
5241
|
if (current !== serialized) {
|
|
5054
|
-
|
|
5242
|
+
fs13.writeFileSync(statusFilePath, serialized, "utf8");
|
|
5055
5243
|
}
|
|
5056
5244
|
return canon;
|
|
5057
5245
|
}
|
|
@@ -5060,7 +5248,7 @@ function refreshCanonSnapshotsAtPaths(statusFilePaths, options = {}) {
|
|
|
5060
5248
|
}
|
|
5061
5249
|
|
|
5062
5250
|
// src/orchestrator/quality-log.ts
|
|
5063
|
-
import
|
|
5251
|
+
import fs14 from "fs";
|
|
5064
5252
|
import path13 from "path";
|
|
5065
5253
|
var CANON_LOG_HEADERS = [
|
|
5066
5254
|
"Date",
|
|
@@ -5255,11 +5443,11 @@ function renderRowLine(headerCells, row) {
|
|
|
5255
5443
|
function writeFileAtomic(filePath, content) {
|
|
5256
5444
|
const tempPath = `${filePath}.tmp`;
|
|
5257
5445
|
try {
|
|
5258
|
-
|
|
5259
|
-
|
|
5446
|
+
fs14.writeFileSync(tempPath, content, "utf8");
|
|
5447
|
+
fs14.renameSync(tempPath, filePath);
|
|
5260
5448
|
} finally {
|
|
5261
5449
|
try {
|
|
5262
|
-
|
|
5450
|
+
fs14.unlinkSync(tempPath);
|
|
5263
5451
|
} catch {
|
|
5264
5452
|
}
|
|
5265
5453
|
}
|
|
@@ -5268,7 +5456,7 @@ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
|
|
|
5268
5456
|
try {
|
|
5269
5457
|
let content;
|
|
5270
5458
|
try {
|
|
5271
|
-
content =
|
|
5459
|
+
content = fs14.readFileSync(logFilePath, "utf8");
|
|
5272
5460
|
} catch (error) {
|
|
5273
5461
|
if (error.code === "ENOENT") {
|
|
5274
5462
|
content = STANDARD_QUALITY_LOG_SKELETON;
|
|
@@ -5327,7 +5515,7 @@ function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
|
|
|
5327
5515
|
try {
|
|
5328
5516
|
let doneContent = "";
|
|
5329
5517
|
try {
|
|
5330
|
-
doneContent =
|
|
5518
|
+
doneContent = fs14.readFileSync(donePath, "utf8");
|
|
5331
5519
|
} catch {
|
|
5332
5520
|
}
|
|
5333
5521
|
upsertQualityLogRow(
|
|
@@ -5381,7 +5569,7 @@ function taskRootForGate(cwd) {
|
|
|
5381
5569
|
}
|
|
5382
5570
|
function readJsonFile(filePath) {
|
|
5383
5571
|
try {
|
|
5384
|
-
return JSON.parse(
|
|
5572
|
+
return JSON.parse(fs15.readFileSync(filePath, "utf8"));
|
|
5385
5573
|
} catch (error) {
|
|
5386
5574
|
const message = error instanceof Error ? error.message : String(error);
|
|
5387
5575
|
throw new Error(`Error: failed to read ${filePath}: ${message}`);
|
|
@@ -5389,9 +5577,9 @@ function readJsonFile(filePath) {
|
|
|
5389
5577
|
}
|
|
5390
5578
|
function writeJsonAtomic(filePath, data) {
|
|
5391
5579
|
const tmpFile = `${filePath}.tmp`;
|
|
5392
|
-
|
|
5580
|
+
fs15.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
|
|
5393
5581
|
`, "utf8");
|
|
5394
|
-
|
|
5582
|
+
fs15.renameSync(tmpFile, filePath);
|
|
5395
5583
|
}
|
|
5396
5584
|
function writeStatusAtomic(filePath, status) {
|
|
5397
5585
|
status.status = deriveTopLevelStatus(status);
|
|
@@ -5464,7 +5652,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
|
|
|
5464
5652
|
assertValidVerdict(phaseArg, verdictArg);
|
|
5465
5653
|
const taskCwd = resolveTaskCwd(id);
|
|
5466
5654
|
const statusPath = taskStatusFileForCwd(taskCwd, id);
|
|
5467
|
-
if (!
|
|
5655
|
+
if (!fs15.existsSync(statusPath)) {
|
|
5468
5656
|
throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
|
|
5469
5657
|
}
|
|
5470
5658
|
const status = readJsonFile(statusPath);
|
|
@@ -5526,7 +5714,7 @@ function taskPhasePreflightRejected(id, phaseArg) {
|
|
|
5526
5714
|
}
|
|
5527
5715
|
const taskCwd = resolveTaskCwd(id);
|
|
5528
5716
|
const statusPath = taskStatusFileForCwd(taskCwd, id);
|
|
5529
|
-
if (!
|
|
5717
|
+
if (!fs15.existsSync(statusPath)) {
|
|
5530
5718
|
throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
|
|
5531
5719
|
}
|
|
5532
5720
|
const status = readJsonFile(statusPath);
|
|
@@ -5549,6 +5737,14 @@ function taskPhasePreflightRejected(id, phaseArg) {
|
|
|
5549
5737
|
}
|
|
5550
5738
|
|
|
5551
5739
|
// src/orchestrator/phases/code-review.ts
|
|
5740
|
+
function findUnjudgedFullSendFilesFromData(changedFiles, allowlist, taskIds, verdicts) {
|
|
5741
|
+
if (verdicts.some((verdict) => verdict === "changes_requested" || verdict === "needs_re_review" || verdict === "spec_gap")) return [];
|
|
5742
|
+
return verifyBaseDriftFromData(changedFiles, allowlist.paths, taskIds, allowlist.prefixes);
|
|
5743
|
+
}
|
|
5744
|
+
function fullSendScopeBlockReason(taskIds, files, recordedVerdicts) {
|
|
5745
|
+
const verdictNote = recordedVerdicts.length > 0 ? ` Recorded code-review verdict(s): ${recordedVerdicts.join(", ")}; inspect the review before resuming.` : "";
|
|
5746
|
+
return `Full-send code review left out-of-scope files unjudged: ${files.join(", ")}. Add them to a task spec's ### Affected Files table, then re-run \`canon run ${taskIds.join(" ")}\`; or reroute with a note telling the implementer to remove them.${verdictNote}`;
|
|
5747
|
+
}
|
|
5552
5748
|
var defaultDeps = {
|
|
5553
5749
|
verifyBranch,
|
|
5554
5750
|
getBaseBranch,
|
|
@@ -5556,6 +5752,15 @@ var defaultDeps = {
|
|
|
5556
5752
|
getAffectedFiles,
|
|
5557
5753
|
verifyHandoffAgainstDiff,
|
|
5558
5754
|
getScopedDiff,
|
|
5755
|
+
getScopedDiffInRange,
|
|
5756
|
+
getPathsInRange,
|
|
5757
|
+
getDeltaLineStats,
|
|
5758
|
+
resolveCommit,
|
|
5759
|
+
isAncestorCommit,
|
|
5760
|
+
getEffectiveSize: getEffectiveSize2,
|
|
5761
|
+
findColdCodexArchiveForRound,
|
|
5762
|
+
hasMalformedColdCodexArchive,
|
|
5763
|
+
writeColdCodexArchive,
|
|
5559
5764
|
getClaudeConfig,
|
|
5560
5765
|
getMaxReviewLoops,
|
|
5561
5766
|
getCodexConfig,
|
|
@@ -5684,7 +5889,7 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
|
|
|
5684
5889
|
const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
|
|
5685
5890
|
let existing = "";
|
|
5686
5891
|
try {
|
|
5687
|
-
existing =
|
|
5892
|
+
existing = fs16.readFileSync(reviewPath, "utf8");
|
|
5688
5893
|
} catch {
|
|
5689
5894
|
}
|
|
5690
5895
|
const hasH2Stage1 = /^## Stage 1\b/m.test(existing);
|
|
@@ -5700,7 +5905,7 @@ function writePreflightReviewArtifacts(tasks, preflightFailed, route) {
|
|
|
5700
5905
|
${blockedBlock}` : `# Code Review: ${t.taskId}
|
|
5701
5906
|
|
|
5702
5907
|
${blockedBlock}`;
|
|
5703
|
-
|
|
5908
|
+
fs16.writeFileSync(reviewPath, reviewContent2, "utf8");
|
|
5704
5909
|
continue;
|
|
5705
5910
|
}
|
|
5706
5911
|
const currentPreflight = t.status.phases.code_review?.preflight_rejections_current_loop ?? 0;
|
|
@@ -5711,7 +5916,7 @@ ${blockedBlock}`;
|
|
|
5711
5916
|
---
|
|
5712
5917
|
|
|
5713
5918
|
${stub}` : stub;
|
|
5714
|
-
|
|
5919
|
+
fs16.writeFileSync(reviewPath, reviewContent, "utf8");
|
|
5715
5920
|
}
|
|
5716
5921
|
return true;
|
|
5717
5922
|
}
|
|
@@ -5756,11 +5961,63 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
|
|
|
5756
5961
|
}
|
|
5757
5962
|
return { agent: "claude", sessionId: null, exitCode: 0 };
|
|
5758
5963
|
}
|
|
5964
|
+
const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs: true });
|
|
5965
|
+
const outOfScopeFiles = verifyBaseDriftFromData(
|
|
5966
|
+
[...changedFiles],
|
|
5967
|
+
allowlist.paths,
|
|
5968
|
+
taskIds,
|
|
5969
|
+
allowlist.prefixes
|
|
5970
|
+
);
|
|
5971
|
+
const scopeFullSend = tasks.every((t) => t.status.full_send === true);
|
|
5972
|
+
if (outOfScopeFiles.length > 0 && !scopeFullSend) {
|
|
5973
|
+
const reason = `Code review pre-flight found files outside the spec's Affected Files for ${taskIds.join(", ")}:
|
|
5974
|
+
` + outOfScopeFiles.map((file) => ` ${file}`).join("\n") + `
|
|
5975
|
+
Add them to the affected task spec's ### Affected Files table in the active checkout, then re-run \`canon run ${taskIds.join(" ")}\`; or reroute with a note telling the implementer to remove them.`;
|
|
5976
|
+
warn(reason);
|
|
5977
|
+
autoBlockPhase(taskIds, "code_review", codeReviewCheck.count, reason);
|
|
5978
|
+
process.exit(2);
|
|
5979
|
+
}
|
|
5980
|
+
const headSha = deps.resolveCommit("HEAD", activeCwd);
|
|
5981
|
+
if (!headSha) {
|
|
5982
|
+
setExitReason(`Cannot start code_review for task(s) ${taskIds.join(", ")}: HEAD does not resolve to a commit.`);
|
|
5983
|
+
process.exit(1);
|
|
5984
|
+
}
|
|
5759
5985
|
info(`Phase: code_review (Claude${state.isBundle ? " bundle" : ""}, iteration ${maxIter + 1})`);
|
|
5760
5986
|
for (const t of tasks) taskPhase(t.taskId, "code_review", "in_progress");
|
|
5987
|
+
const { isRound1, roundN } = resolveCodeReviewRound(tasks);
|
|
5988
|
+
const effectiveSize = deps.getEffectiveSize(tasks);
|
|
5989
|
+
const delicate = tasks.some((t) => t.status.delicate === true);
|
|
5990
|
+
const records = !isRound1 && effectiveSize !== "XL" ? tasks.map((t) => deps.findColdCodexArchiveForRound(taskDirFor(t.taskId), roundN - 1)) : [];
|
|
5991
|
+
const agreed = records.length === tasks.length && records.every((record) => record !== null && record.reviewedSha === records[0]?.reviewedSha);
|
|
5992
|
+
const previousRecordsDisagree = records.length === tasks.length && records.every((record) => record !== null) && !agreed;
|
|
5993
|
+
const previousArchiveMalformed = records.some((record, index) => record === null && deps.hasMalformedColdCodexArchive(taskDirFor(tasks[index].taskId)));
|
|
5994
|
+
const reviewedSha = agreed ? records[0]?.reviewedSha : void 0;
|
|
5995
|
+
const resolved = reviewedSha ? deps.resolveCommit(reviewedSha, activeCwd) : null;
|
|
5996
|
+
const prevRecord = reviewedSha ? {
|
|
5997
|
+
reviewedSha,
|
|
5998
|
+
exists: resolved !== null,
|
|
5999
|
+
isAncestor: resolved !== null && deps.isAncestorCommit(reviewedSha, headSha, activeCwd),
|
|
6000
|
+
equalsHead: resolved !== null && resolved === headSha
|
|
6001
|
+
} : null;
|
|
6002
|
+
const canProbe = prevRecord?.exists === true && prevRecord.isAncestor && !prevRecord.equalsHead;
|
|
6003
|
+
const scope = resolveCodeReviewScope({
|
|
6004
|
+
isRound1,
|
|
6005
|
+
effectiveSize,
|
|
6006
|
+
delicate,
|
|
6007
|
+
baseBranch,
|
|
6008
|
+
prevRecord,
|
|
6009
|
+
previousArchiveMalformed,
|
|
6010
|
+
previousRecordsDisagree,
|
|
6011
|
+
deltaPaths: canProbe ? deps.getPathsInRange(`${reviewedSha}..${headSha}`, activeCwd) : [],
|
|
6012
|
+
priorChangeSetPaths: canProbe ? deps.getPathsInRange(`${baseBranch}...${reviewedSha}`, activeCwd) : [],
|
|
6013
|
+
deltaFileStats: canProbe && reviewedSha ? deps.getDeltaLineStats(reviewedSha, activeCwd, headSha) : [],
|
|
6014
|
+
taskIds,
|
|
6015
|
+
telemetryFiles: PIPELINE_TELEMETRY_FILES
|
|
6016
|
+
});
|
|
6017
|
+
const deltaDiff = scope.scope === "delta" ? deps.getScopedDiffInRange(`${scope.base}..${headSha}`, activeCwd) : null;
|
|
5761
6018
|
const coldCfg = deps.getCodexConfig("code_review", tasks);
|
|
5762
6019
|
const coldReviewStartMs = Date.now();
|
|
5763
|
-
const coldReview = await deps.runColdCodexReview(
|
|
6020
|
+
const coldReview = await deps.runColdCodexReview(scope.base, coldCfg.model, coldCfg.effort, activeCwd, {
|
|
5764
6021
|
taskId: taskIds.join("+"),
|
|
5765
6022
|
phase: "code_review",
|
|
5766
6023
|
iteration: maxIter,
|
|
@@ -5774,17 +6031,26 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
|
|
|
5774
6031
|
process.exit(1);
|
|
5775
6032
|
}
|
|
5776
6033
|
for (const t of tasks) {
|
|
5777
|
-
|
|
6034
|
+
fs16.writeFileSync(
|
|
5778
6035
|
path15.join(taskDirFor(t.taskId), "review-cold-codex.md"),
|
|
5779
6036
|
coldReview.findings,
|
|
5780
6037
|
"utf8"
|
|
5781
6038
|
);
|
|
5782
6039
|
}
|
|
6040
|
+
for (const t of tasks) {
|
|
6041
|
+
deps.writeColdCodexArchive(taskDirFor(t.taskId), {
|
|
6042
|
+
round: roundN,
|
|
6043
|
+
reviewedSha: headSha,
|
|
6044
|
+
scope: scope.scope,
|
|
6045
|
+
base: scope.base,
|
|
6046
|
+
reason: scope.reason
|
|
6047
|
+
}, coldReview.findings);
|
|
6048
|
+
}
|
|
5783
6049
|
info(`\u2192 cold-codex review (${taskIds.join(", ")}): ${Math.round(coldReviewDurationMs / 1e3)}s`);
|
|
5784
6050
|
const cfg = deps.getClaudeConfig("code_review", tasks);
|
|
5785
6051
|
const reviewResumeId = maxIter > 0 ? resumeId : null;
|
|
5786
|
-
const scopedDiff = deps.getScopedDiff(baseBranch, activeCwd);
|
|
5787
|
-
const result = await deps.runClaude(promptCodeReview(state, baseBranch, scopedDiff, coldReview.findings), interactive, reviewResumeId, cfg.model, cfg.effort, cfg.budget, {
|
|
6052
|
+
const scopedDiff = scope.scope === "delta" ? null : deps.getScopedDiff(baseBranch, activeCwd);
|
|
6053
|
+
const result = await deps.runClaude(promptCodeReview(state, baseBranch, scopedDiff, coldReview.findings, { ...scope, deltaDiff }, outOfScopeFiles), interactive, reviewResumeId, cfg.model, cfg.effort, cfg.budget, {
|
|
5788
6054
|
taskId: taskIds.join("+"),
|
|
5789
6055
|
phase: "code_review",
|
|
5790
6056
|
iteration: maxIter,
|
|
@@ -5794,7 +6060,7 @@ async function runCodeReviewPhase(state, interactive, resumeId, deps = defaultDe
|
|
|
5794
6060
|
const reviewPath = path15.join(taskDirFor(t.taskId), "review.md");
|
|
5795
6061
|
let reviewContent = null;
|
|
5796
6062
|
try {
|
|
5797
|
-
reviewContent =
|
|
6063
|
+
reviewContent = fs16.readFileSync(reviewPath, "utf8");
|
|
5798
6064
|
} catch {
|
|
5799
6065
|
}
|
|
5800
6066
|
if (isTemplateUnfilled(reviewContent)) {
|
|
@@ -5895,7 +6161,7 @@ async function runImplementPhase(state, interactive, resumeId, force = false) {
|
|
|
5895
6161
|
}
|
|
5896
6162
|
|
|
5897
6163
|
// src/orchestrator/phases/plan.ts
|
|
5898
|
-
import
|
|
6164
|
+
import fs17 from "fs";
|
|
5899
6165
|
import path16 from "path";
|
|
5900
6166
|
async function runPlanPhase(state, interactive) {
|
|
5901
6167
|
const { tasks } = state;
|
|
@@ -5914,7 +6180,7 @@ async function runPlanPhase(state, interactive) {
|
|
|
5914
6180
|
const planPath = path16.join(taskDirFor(t.taskId), "plan.md");
|
|
5915
6181
|
let planContent = null;
|
|
5916
6182
|
try {
|
|
5917
|
-
planContent =
|
|
6183
|
+
planContent = fs17.readFileSync(planPath, "utf8");
|
|
5918
6184
|
} catch {
|
|
5919
6185
|
}
|
|
5920
6186
|
if (isTemplateUnfilled(planContent)) {
|
|
@@ -5926,7 +6192,7 @@ async function runPlanPhase(state, interactive) {
|
|
|
5926
6192
|
}
|
|
5927
6193
|
|
|
5928
6194
|
// src/orchestrator/phases/qa.ts
|
|
5929
|
-
import
|
|
6195
|
+
import fs18 from "fs";
|
|
5930
6196
|
import path17 from "path";
|
|
5931
6197
|
async function runQaPhase(state, interactive, resolvedPrTemplate) {
|
|
5932
6198
|
const { tasks } = state;
|
|
@@ -5948,7 +6214,7 @@ async function runQaPhase(state, interactive, resolvedPrTemplate) {
|
|
|
5948
6214
|
if (isDoneMdTemplate(donePath)) {
|
|
5949
6215
|
const salvaged = extractDoneMdFromStdout(result.capturedStdout);
|
|
5950
6216
|
if (salvaged) {
|
|
5951
|
-
|
|
6217
|
+
fs18.writeFileSync(donePath, salvaged);
|
|
5952
6218
|
warn(`Salvaged tasks/${taskId}/done.md from captured stdout \u2014 QA sub-agent streamed content instead of using the Write tool.`);
|
|
5953
6219
|
const phaseStatus = readStatus(taskId).phases.qa?.status ?? "pending";
|
|
5954
6220
|
if (phaseStatus !== "done") {
|
|
@@ -6000,7 +6266,7 @@ async function runSpecPhase(state, interactive, resumeId) {
|
|
|
6000
6266
|
}
|
|
6001
6267
|
|
|
6002
6268
|
// src/orchestrator/phases/spec-review.ts
|
|
6003
|
-
import
|
|
6269
|
+
import fs19 from "fs";
|
|
6004
6270
|
import path18 from "path";
|
|
6005
6271
|
function autoBlockSpecReview(taskIds, iterationCount, reason) {
|
|
6006
6272
|
autoBlockPhase(taskIds, "spec_review", iterationCount, reason);
|
|
@@ -6009,14 +6275,14 @@ function recordFastTierSpecApproval(taskId) {
|
|
|
6009
6275
|
const artifactPath = path18.join(taskDirFor(taskId), "spec-review.md");
|
|
6010
6276
|
let content;
|
|
6011
6277
|
try {
|
|
6012
|
-
content =
|
|
6278
|
+
content = fs19.readFileSync(artifactPath, "utf8");
|
|
6013
6279
|
} catch {
|
|
6014
6280
|
return;
|
|
6015
6281
|
}
|
|
6016
6282
|
if (extractCheckedVerdict(content)) return;
|
|
6017
6283
|
const checked = content.replace(/^- \[ \] (\*\*Approved\*\*)/m, "- [x] $1");
|
|
6018
6284
|
const note = "\n> Fast tier: Codex spec review skipped \u2014 human conversational spec approval recorded by the orchestrator.\n";
|
|
6019
|
-
|
|
6285
|
+
fs19.writeFileSync(
|
|
6020
6286
|
artifactPath,
|
|
6021
6287
|
(checked !== content ? checked : `${content}
|
|
6022
6288
|
## Verdict
|
|
@@ -6088,7 +6354,7 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
|
|
|
6088
6354
|
const reviewPath = path18.join(resolveTaskCwd(t.taskId), "tasks", t.taskId, "spec-review.md");
|
|
6089
6355
|
let reviewContent = null;
|
|
6090
6356
|
try {
|
|
6091
|
-
reviewContent =
|
|
6357
|
+
reviewContent = fs19.readFileSync(reviewPath, "utf8");
|
|
6092
6358
|
} catch {
|
|
6093
6359
|
}
|
|
6094
6360
|
if (isTemplateUnfilled(reviewContent)) {
|
|
@@ -6101,7 +6367,7 @@ ${promptSpecReview(state)}` : promptSpecReview(state);
|
|
|
6101
6367
|
|
|
6102
6368
|
// src/orchestrator/detach.ts
|
|
6103
6369
|
import { spawn as spawn3 } from "child_process";
|
|
6104
|
-
import
|
|
6370
|
+
import fs20 from "fs";
|
|
6105
6371
|
import path19 from "path";
|
|
6106
6372
|
var DETACH_CHILD_FLAG = "CANON_DETACHED";
|
|
6107
6373
|
var DETACH_DISABLE_FLAG = "CANON_NO_DETACH";
|
|
@@ -6138,7 +6404,7 @@ function detachAndExit(options) {
|
|
|
6138
6404
|
}
|
|
6139
6405
|
const primaryDir = options.resolveTaskDir(options.taskIds[0]);
|
|
6140
6406
|
try {
|
|
6141
|
-
|
|
6407
|
+
fs20.mkdirSync(primaryDir, { recursive: true });
|
|
6142
6408
|
} catch (error) {
|
|
6143
6409
|
stderrWrite(`canon: cannot create task dir for log file: ${error.message}
|
|
6144
6410
|
`);
|
|
@@ -6147,7 +6413,7 @@ function detachAndExit(options) {
|
|
|
6147
6413
|
const logPath = path19.join(primaryDir, LOG_FILENAME);
|
|
6148
6414
|
let logFd;
|
|
6149
6415
|
try {
|
|
6150
|
-
logFd =
|
|
6416
|
+
logFd = fs20.openSync(logPath, "a");
|
|
6151
6417
|
} catch (error) {
|
|
6152
6418
|
stderrWrite(`canon: cannot open ${logPath}: ${error.message}
|
|
6153
6419
|
`);
|
|
@@ -6160,7 +6426,7 @@ function detachAndExit(options) {
|
|
|
6160
6426
|
env: { ...process.env, [DETACH_CHILD_FLAG]: "1" }
|
|
6161
6427
|
});
|
|
6162
6428
|
try {
|
|
6163
|
-
|
|
6429
|
+
fs20.closeSync(logFd);
|
|
6164
6430
|
} catch {
|
|
6165
6431
|
}
|
|
6166
6432
|
if (child.pid == null) {
|
|
@@ -6171,8 +6437,8 @@ function detachAndExit(options) {
|
|
|
6171
6437
|
for (const taskId of options.taskIds) {
|
|
6172
6438
|
try {
|
|
6173
6439
|
const dir = options.resolveTaskDir(taskId);
|
|
6174
|
-
|
|
6175
|
-
|
|
6440
|
+
fs20.mkdirSync(dir, { recursive: true });
|
|
6441
|
+
fs20.writeFileSync(path19.join(dir, PID_FILENAME), `${child.pid}
|
|
6176
6442
|
`, "utf8");
|
|
6177
6443
|
} catch (error) {
|
|
6178
6444
|
pidWriteFailures.push({
|
|
@@ -6228,7 +6494,7 @@ ${rule}
|
|
|
6228
6494
|
function readCanonPid(taskDir) {
|
|
6229
6495
|
const file = path19.join(taskDir, PID_FILENAME);
|
|
6230
6496
|
try {
|
|
6231
|
-
const raw =
|
|
6497
|
+
const raw = fs20.readFileSync(file, "utf8").trim();
|
|
6232
6498
|
const pid = Number.parseInt(raw, 10);
|
|
6233
6499
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
6234
6500
|
} catch {
|
|
@@ -6237,7 +6503,7 @@ function readCanonPid(taskDir) {
|
|
|
6237
6503
|
}
|
|
6238
6504
|
function removeCanonPid(taskDir) {
|
|
6239
6505
|
try {
|
|
6240
|
-
|
|
6506
|
+
fs20.unlinkSync(path19.join(taskDir, PID_FILENAME));
|
|
6241
6507
|
} catch {
|
|
6242
6508
|
}
|
|
6243
6509
|
}
|
|
@@ -6499,16 +6765,14 @@ function autoCommitCode(taskIds, cwd = REPO_ROOT2) {
|
|
|
6499
6765
|
const toStage = handoffFiles.filter((f) => dirtyFiles.has(f));
|
|
6500
6766
|
const gitIgnoredHandoffFiles = filterGitIgnoredPaths(handoffFiles, cwd);
|
|
6501
6767
|
const missing = [];
|
|
6502
|
-
const settledDeletions = /* @__PURE__ */ new Set();
|
|
6503
6768
|
const baseRefForLog = getBaseBranch(taskIds);
|
|
6504
6769
|
for (const f of allHandoffFiles) {
|
|
6505
6770
|
if (dirtyFiles.has(f)) continue;
|
|
6506
6771
|
if (gitIgnoredHandoffFiles.has(f)) continue;
|
|
6507
|
-
const exists =
|
|
6772
|
+
const exists = fs21.existsSync(path20.join(cwd, f));
|
|
6508
6773
|
if (!exists) {
|
|
6509
6774
|
const committed = gitSafeAt(cwd, "log", "--format=%H", "--max-count=1", `${baseRefForLog}..HEAD`, "--", f);
|
|
6510
6775
|
if (committed.ok && committed.stdout.trim()) {
|
|
6511
|
-
settledDeletions.add(f);
|
|
6512
6776
|
continue;
|
|
6513
6777
|
}
|
|
6514
6778
|
missing.push(`${f} \u2014 listed in handoff but missing from working tree (and no commit in ${baseRefForLog}..HEAD touches this path)`);
|
|
@@ -6544,14 +6808,11 @@ ${stagedBeforeUnexpected.map((f) => ` ${f}`).join("\n")}
|
|
|
6544
6808
|
info("Handoff files are already committed or unchanged \u2014 skipping auto-commit.");
|
|
6545
6809
|
return;
|
|
6546
6810
|
}
|
|
6547
|
-
const stageable = handoffFiles
|
|
6548
|
-
if (stageable.length
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
return;
|
|
6811
|
+
const stageable = filterStageablePaths(handoffFiles, cwd);
|
|
6812
|
+
if (stageable.length > 0) {
|
|
6813
|
+
const addResult = gitSafeAt(cwd, "add", "-A", "--", ...stageable);
|
|
6814
|
+
if (!addResult.ok) die2(`Failed to stage files: ${addResult.stderr || "unknown error"}`);
|
|
6552
6815
|
}
|
|
6553
|
-
const addResult = gitSafeAt(cwd, "add", "-A", "--", ...stageable);
|
|
6554
|
-
if (!addResult.ok) die2(`Failed to stage files: ${addResult.stderr || "unknown error"}`);
|
|
6555
6816
|
const preCheck = gitSafeAtRaw(cwd, "status", "--porcelain=v1", "-uall");
|
|
6556
6817
|
const remaining = preCheck.ok ? findUncoveredTrackedChanges(preCheck.stdout, allHandoffFiles) : [];
|
|
6557
6818
|
const stagedAfter = gitSafeAt(cwd, "diff", "--cached", "--name-only");
|
|
@@ -6702,8 +6963,8 @@ function exemptNodeModulesPath(entry, cwd, resolvedWorkspaceDirs) {
|
|
|
6702
6963
|
let cwdReal;
|
|
6703
6964
|
let repoRootReal;
|
|
6704
6965
|
try {
|
|
6705
|
-
cwdReal =
|
|
6706
|
-
repoRootReal =
|
|
6966
|
+
cwdReal = fs21.realpathSync(cwd);
|
|
6967
|
+
repoRootReal = fs21.realpathSync(REPO_ROOT2);
|
|
6707
6968
|
} catch {
|
|
6708
6969
|
return null;
|
|
6709
6970
|
}
|
|
@@ -6767,7 +7028,7 @@ function findPullRequestTemplate(repoRoot) {
|
|
|
6767
7028
|
path20.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
|
|
6768
7029
|
];
|
|
6769
7030
|
for (const candidate of candidates) {
|
|
6770
|
-
if (
|
|
7031
|
+
if (fs21.existsSync(candidate)) return candidate;
|
|
6771
7032
|
}
|
|
6772
7033
|
return null;
|
|
6773
7034
|
}
|
|
@@ -6787,7 +7048,7 @@ function resolveQaPrBody(taskIds, activeCwd) {
|
|
|
6787
7048
|
}
|
|
6788
7049
|
return {
|
|
6789
7050
|
kind: "fallback",
|
|
6790
|
-
reason:
|
|
7051
|
+
reason: fs21.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
|
|
6791
7052
|
};
|
|
6792
7053
|
}
|
|
6793
7054
|
function commitQaArtifacts(taskIds, cwd) {
|
|
@@ -6832,7 +7093,7 @@ Source or test edits must be committed during the implement phase, not left dirt
|
|
|
6832
7093
|
` + stagedBeforeUnexpected.map((filePath) => ` ${filePath}`).join("\n")
|
|
6833
7094
|
);
|
|
6834
7095
|
}
|
|
6835
|
-
for (const relPath of stagePaths) {
|
|
7096
|
+
for (const relPath of filterStageablePaths([...stagePaths], cwd)) {
|
|
6836
7097
|
const exclusions = nodeModulesExclusionArgs(relPath, exemptNodeModulesPaths);
|
|
6837
7098
|
const addResult = gitSafeAt2(cwd, "add", "-A", "--", relPath, ...exclusions);
|
|
6838
7099
|
if (!addResult.ok) {
|
|
@@ -6915,8 +7176,8 @@ function recordPinnedPRNumber(taskIds, prNum) {
|
|
|
6915
7176
|
if (alreadyPinned) return;
|
|
6916
7177
|
for (const taskId of taskIds) {
|
|
6917
7178
|
const sidecarPath = sidecarPathFor(taskId);
|
|
6918
|
-
|
|
6919
|
-
|
|
7179
|
+
fs21.mkdirSync(path20.dirname(sidecarPath), { recursive: true });
|
|
7180
|
+
fs21.writeFileSync(sidecarPath, String(prNum), "utf8");
|
|
6920
7181
|
}
|
|
6921
7182
|
}
|
|
6922
7183
|
function reportOrCreatePR(taskIds, branchName) {
|
|
@@ -7037,7 +7298,7 @@ function commitHumanReviewFiles(taskIds, cwd, createPR) {
|
|
|
7037
7298
|
);
|
|
7038
7299
|
}
|
|
7039
7300
|
const docsRefsScript = path20.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
|
|
7040
|
-
if (
|
|
7301
|
+
if (fs21.existsSync(docsRefsScript)) {
|
|
7041
7302
|
const docsRefsResult = spawnSync6("node", [docsRefsScript], {
|
|
7042
7303
|
cwd,
|
|
7043
7304
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -7067,27 +7328,12 @@ This failure cannot be bypassed with --force.`
|
|
|
7067
7328
|
);
|
|
7068
7329
|
} else if (baseDriftResult.drift.length > 0) {
|
|
7069
7330
|
if (!cliArgs.force) {
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
The allowlist is: tasks/<id>/**, PIPELINE_TELEMETRY_FILES, files listed in
|
|
7075
|
-
your spec's '### Affected Files' table (directory-form entries like 'dist/' match
|
|
7076
|
-
subpaths), and PIPELINE_MANAGED_DOCS (auto-allowlisted once qa.status = done).
|
|
7077
|
-
If this is a legitimate task change, add the path to spec.md '### Affected Files'
|
|
7078
|
-
and rerun. For a rename, list BOTH the old and new paths. If the drift is
|
|
7079
|
-
unexpected (likely cross-pipeline contamination from a sibling worktree's
|
|
7080
|
-
managed-doc sync, OR a third-party commit landed on origin/${baseBranch} while
|
|
7081
|
-
this pipeline was running), recover with one of:
|
|
7082
|
-
- rebase onto current origin/${baseBranch} to absorb the base advance:
|
|
7083
|
-
git fetch origin ${baseBranch} && git rebase origin/${baseBranch}
|
|
7084
|
-
- reset a specific file to base's content if a stray task-branch commit
|
|
7085
|
-
introduced it:
|
|
7086
|
-
git checkout origin/${baseBranch} -- <path> && git commit -m 'revert drift on <path>'
|
|
7087
|
-
- revert the offending task-branch commit entirely:
|
|
7088
|
-
git revert <sha>
|
|
7089
|
-
Bypass with --force if you've verified the drift is intentional.`
|
|
7331
|
+
const taskChangedFiles = new Set(getAffectedFiles(`origin/${baseBranch}`, cwd));
|
|
7332
|
+
const classification = classifyBaseDriftFilesFromData(
|
|
7333
|
+
baseDriftResult.drift,
|
|
7334
|
+
taskChangedFiles
|
|
7090
7335
|
);
|
|
7336
|
+
die2(buildBaseDriftAbortMessage(baseBranch, classification));
|
|
7091
7337
|
}
|
|
7092
7338
|
warn2(
|
|
7093
7339
|
`--force override: base-drift detected; proceeding at user request. Drifted files:
|
|
@@ -7191,7 +7437,7 @@ If this is a source or test file, it should have been committed during the imple
|
|
|
7191
7437
|
Unstage them or list them in the task artifacts before rerunning.`
|
|
7192
7438
|
);
|
|
7193
7439
|
}
|
|
7194
|
-
for (const relPath of stagePaths) {
|
|
7440
|
+
for (const relPath of filterStageablePaths([...stagePaths], cwd)) {
|
|
7195
7441
|
const exclusions = nodeModulesExclusionArgs(relPath, exemptNodeModulesPaths);
|
|
7196
7442
|
const addResult = gitSafeAt2(cwd, "add", "-A", "--", relPath, ...exclusions);
|
|
7197
7443
|
if (!addResult.ok) {
|
|
@@ -7399,7 +7645,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
|
|
|
7399
7645
|
const sidecarPath = sidecarPathFor(taskId, taskDir);
|
|
7400
7646
|
let raw;
|
|
7401
7647
|
try {
|
|
7402
|
-
raw =
|
|
7648
|
+
raw = fs21.readFileSync(sidecarPath, "utf8").trim();
|
|
7403
7649
|
} catch {
|
|
7404
7650
|
return null;
|
|
7405
7651
|
}
|
|
@@ -7546,7 +7792,7 @@ function mergeOpenPRsAndPull(taskIds, baseBranch, branchByTaskId) {
|
|
|
7546
7792
|
}
|
|
7547
7793
|
function runPostMergeHook() {
|
|
7548
7794
|
const hookPath = path20.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
|
|
7549
|
-
if (!
|
|
7795
|
+
if (!fs21.existsSync(hookPath)) return;
|
|
7550
7796
|
info2("Running .canon/hooks/post-merge.sh...");
|
|
7551
7797
|
const result = runCommand2("bash", [hookPath]);
|
|
7552
7798
|
if (!result.ok) {
|
|
@@ -7574,8 +7820,8 @@ function rewriteArchivedTaskRefs(taskIds) {
|
|
|
7574
7820
|
path20.join(REPO_ROOT2, "docs", "task-quality-log.md")
|
|
7575
7821
|
];
|
|
7576
7822
|
for (const filePath of targets) {
|
|
7577
|
-
if (!
|
|
7578
|
-
let content =
|
|
7823
|
+
if (!fs21.existsSync(filePath)) continue;
|
|
7824
|
+
let content = fs21.readFileSync(filePath, "utf8");
|
|
7579
7825
|
let changed = false;
|
|
7580
7826
|
for (const taskId of taskIds) {
|
|
7581
7827
|
const stale = `tasks/${taskId}/`;
|
|
@@ -7586,7 +7832,7 @@ function rewriteArchivedTaskRefs(taskIds) {
|
|
|
7586
7832
|
}
|
|
7587
7833
|
}
|
|
7588
7834
|
if (changed) {
|
|
7589
|
-
|
|
7835
|
+
fs21.writeFileSync(filePath, content, "utf8");
|
|
7590
7836
|
info2(`Updated stale task refs in ${path20.relative(REPO_ROOT2, filePath)}.`);
|
|
7591
7837
|
}
|
|
7592
7838
|
}
|
|
@@ -7620,7 +7866,7 @@ function classifyAndPreserveSharedDocDirt() {
|
|
|
7620
7866
|
if (porcelainCode !== " M") {
|
|
7621
7867
|
return { relPath, docClass, porcelainCode, headContent: null, workingContent: null };
|
|
7622
7868
|
}
|
|
7623
|
-
const workingContent =
|
|
7869
|
+
const workingContent = fs21.readFileSync(path20.join(REPO_ROOT2, relPath), "utf8");
|
|
7624
7870
|
const headResult = gitSafeAtRaw(REPO_ROOT2, "show", `HEAD:${relPath}`);
|
|
7625
7871
|
return {
|
|
7626
7872
|
relPath,
|
|
@@ -7637,11 +7883,11 @@ function classifyAndPreserveSharedDocDirt() {
|
|
|
7637
7883
|
}
|
|
7638
7884
|
const preserve = verdict.preserve;
|
|
7639
7885
|
if (preserve.length === 0) return [];
|
|
7640
|
-
const backupDir =
|
|
7886
|
+
const backupDir = fs21.mkdtempSync(path20.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
|
|
7641
7887
|
const preserved = [];
|
|
7642
7888
|
for (const { relPath, suffix } of preserve) {
|
|
7643
7889
|
const backupPath = path20.join(backupDir, relPath.replace(/[\\/]/g, "__"));
|
|
7644
|
-
|
|
7890
|
+
fs21.writeFileSync(backupPath, suffix, "utf8");
|
|
7645
7891
|
info2(`Preserving uncommitted ${relPath} dirt during --ship; backup: ${backupPath}`);
|
|
7646
7892
|
const checkoutResult = gitSafe("checkout", "HEAD", "--", relPath);
|
|
7647
7893
|
if (!checkoutResult.ok) {
|
|
@@ -7669,7 +7915,7 @@ function shipTasks(taskIds) {
|
|
|
7669
7915
|
path20.join(taskDirForRepoRoot2(taskId), "status.json")
|
|
7670
7916
|
];
|
|
7671
7917
|
for (const candidate of candidates) {
|
|
7672
|
-
if (
|
|
7918
|
+
if (fs21.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
|
|
7673
7919
|
}
|
|
7674
7920
|
const snapshot = taskStatuses.get(taskId);
|
|
7675
7921
|
if (snapshot) return snapshot;
|
|
@@ -7810,7 +8056,7 @@ Recovery:
|
|
|
7810
8056
|
);
|
|
7811
8057
|
}
|
|
7812
8058
|
const archiveDir = path20.join(TASKS_DIR2, "_archive");
|
|
7813
|
-
if (!
|
|
8059
|
+
if (!fs21.existsSync(archiveDir)) fs21.mkdirSync(archiveDir, { recursive: true });
|
|
7814
8060
|
const localBranchesToDelete = [];
|
|
7815
8061
|
for (const taskId of taskIds) {
|
|
7816
8062
|
const { worktree: hasWorktree } = taskSnapshot(taskId);
|
|
@@ -7822,7 +8068,7 @@ Recovery:
|
|
|
7822
8068
|
writeStatusToFile(path20.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
|
|
7823
8069
|
const src = taskDirForRepoRoot2(taskId);
|
|
7824
8070
|
const dest = path20.join(archiveDir, taskId);
|
|
7825
|
-
|
|
8071
|
+
fs21.renameSync(src, dest);
|
|
7826
8072
|
info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
|
|
7827
8073
|
const branchName = taskSnapshot(taskId).branch;
|
|
7828
8074
|
if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
|
|
@@ -7838,8 +8084,8 @@ Recovery:
|
|
|
7838
8084
|
]);
|
|
7839
8085
|
stageArchiveChanges(stagedPaths);
|
|
7840
8086
|
for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
|
|
7841
|
-
|
|
7842
|
-
|
|
8087
|
+
fs21.appendFileSync(path20.join(REPO_ROOT2, relPath), suffix, "utf8");
|
|
8088
|
+
fs21.rmSync(backupPath, { force: true });
|
|
7843
8089
|
info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
|
|
7844
8090
|
}
|
|
7845
8091
|
const archiveCommit = commitArchiveChanges(taskIds, baseBranch);
|
|
@@ -8090,7 +8336,7 @@ async function runPhase(phase, state) {
|
|
|
8090
8336
|
if (phase === "qa") {
|
|
8091
8337
|
const activeCwd = getActiveCwd(taskIds);
|
|
8092
8338
|
const qaTemplatePath = state.isBundle ? null : findPullRequestTemplate(activeCwd) ?? findPullRequestTemplate(REPO_ROOT2);
|
|
8093
|
-
const resolvedPrTemplate = qaTemplatePath ?
|
|
8339
|
+
const resolvedPrTemplate = qaTemplatePath ? fs21.readFileSync(qaTemplatePath, "utf8") : null;
|
|
8094
8340
|
return runQaPhase(state, cliArgs.interactive, resolvedPrTemplate);
|
|
8095
8341
|
}
|
|
8096
8342
|
if (phase === "human_review") {
|
|
@@ -8173,7 +8419,7 @@ var extractCheckedVerdict2 = extractCheckedVerdict;
|
|
|
8173
8419
|
function readArtifact(taskId, name) {
|
|
8174
8420
|
const p = path20.join(taskDirFor2(taskId), name);
|
|
8175
8421
|
try {
|
|
8176
|
-
return
|
|
8422
|
+
return fs21.readFileSync(p, "utf8");
|
|
8177
8423
|
} catch {
|
|
8178
8424
|
return null;
|
|
8179
8425
|
}
|
|
@@ -8197,7 +8443,7 @@ function checkImplementEvidence(taskId) {
|
|
|
8197
8443
|
const sForEvidence = readStatus(taskId);
|
|
8198
8444
|
if (sForEvidence.worktree === true) {
|
|
8199
8445
|
const wt = worktreePath(taskId);
|
|
8200
|
-
if (
|
|
8446
|
+
if (fs21.existsSync(wt)) checkRoots.push(wt);
|
|
8201
8447
|
}
|
|
8202
8448
|
const ignoreCwd = checkRoots[checkRoots.length - 1];
|
|
8203
8449
|
const gitIgnored = filterGitIgnoredPaths(files, ignoreCwd);
|
|
@@ -8209,12 +8455,13 @@ function checkImplementEvidence(taskId) {
|
|
|
8209
8455
|
};
|
|
8210
8456
|
}
|
|
8211
8457
|
const existingFiles = verifiableFiles.filter(
|
|
8212
|
-
(f) => checkRoots.some((root) =>
|
|
8458
|
+
(f) => checkRoots.some((root) => fs21.existsSync(path20.join(root, f)))
|
|
8213
8459
|
);
|
|
8214
8460
|
if (existingFiles.length === 0) {
|
|
8215
8461
|
const evidenceCwd = checkRoots[checkRoots.length - 1];
|
|
8216
|
-
const
|
|
8217
|
-
|
|
8462
|
+
const deletedDiff = gitSafeAt(evidenceCwd, "diff", "HEAD", "--name-only", "--no-renames", "--diff-filter=D");
|
|
8463
|
+
const deletedAgainstHead = new Set(
|
|
8464
|
+
(deletedDiff.ok ? deletedDiff.stdout : "").split("\n").map((l) => l.trim()).filter(Boolean)
|
|
8218
8465
|
);
|
|
8219
8466
|
const baseBranch = sForEvidence.base_branch || getDefaultBaseBranch();
|
|
8220
8467
|
const committedDiff = gitSafeAt(
|
|
@@ -8227,7 +8474,7 @@ function checkImplementEvidence(taskId) {
|
|
|
8227
8474
|
committedDiff.stdout.split("\n").filter((l) => l.startsWith("D")).map((l) => l.split(" ")[1]?.trim()).filter((p) => Boolean(p))
|
|
8228
8475
|
);
|
|
8229
8476
|
const deletedFiles = verifiableFiles.filter(
|
|
8230
|
-
(f) =>
|
|
8477
|
+
(f) => deletedAgainstHead.has(f) || deletedInCommits.has(f)
|
|
8231
8478
|
);
|
|
8232
8479
|
if (deletedFiles.length === 0) {
|
|
8233
8480
|
return { advanced: false, note: `handoff.md lists ${files.length} file(s) but none exist on disk or are git-tracked deletions` };
|
|
@@ -8430,6 +8677,35 @@ async function checkAndRoute(phase, taskIds) {
|
|
|
8430
8677
|
}
|
|
8431
8678
|
}
|
|
8432
8679
|
statuses = taskIds.map(readStatus);
|
|
8680
|
+
let specGapScopeFiles = [];
|
|
8681
|
+
if (phase === "code_review" && statuses.every((status) => status.full_send === true)) {
|
|
8682
|
+
const cwd = getActiveCwd(taskIds);
|
|
8683
|
+
const baseBranch = getBaseBranch(taskIds);
|
|
8684
|
+
const changedFiles = getAffectedFiles(baseBranch, cwd);
|
|
8685
|
+
const allowlist = buildAffectedFilesAllowlist(taskIds, { admitManagedDocs: true });
|
|
8686
|
+
const verdicts = statuses.map((status) => getVerdict(status, "code_review"));
|
|
8687
|
+
if (verdicts.includes("spec_gap")) {
|
|
8688
|
+
specGapScopeFiles = verifyBaseDriftFromData(
|
|
8689
|
+
changedFiles,
|
|
8690
|
+
allowlist.paths,
|
|
8691
|
+
taskIds,
|
|
8692
|
+
allowlist.prefixes
|
|
8693
|
+
);
|
|
8694
|
+
}
|
|
8695
|
+
const unjudged = findUnjudgedFullSendFilesFromData(
|
|
8696
|
+
changedFiles,
|
|
8697
|
+
allowlist,
|
|
8698
|
+
taskIds,
|
|
8699
|
+
verdicts
|
|
8700
|
+
);
|
|
8701
|
+
if (unjudged.length > 0) {
|
|
8702
|
+
const reason = fullSendScopeBlockReason(taskIds, unjudged, verdicts.filter(Boolean));
|
|
8703
|
+
warn2(reason);
|
|
8704
|
+
const maxIter = statuses.reduce((max, status) => Math.max(max, getIterations(status)), 0);
|
|
8705
|
+
autoBlockPhase(taskIds, "code_review", maxIter, reason);
|
|
8706
|
+
process.exit(2);
|
|
8707
|
+
}
|
|
8708
|
+
}
|
|
8433
8709
|
if (lastCodexExitStatus !== 0) {
|
|
8434
8710
|
warn2(`Phase '${phase}' completed despite Codex exit status ${lastCodexExitStatus} (likely MCP warnings). Continuing.`);
|
|
8435
8711
|
lastCodexExitStatus = 0;
|
|
@@ -8505,7 +8781,8 @@ async function checkAndRoute(phase, taskIds) {
|
|
|
8505
8781
|
const specGapIds = taskIds.filter((_, index) => getVerdict(statuses[index], "code_review") === "spec_gap");
|
|
8506
8782
|
if (specGapIds.length > 0) {
|
|
8507
8783
|
const maxIter = statuses.reduce((max, s) => Math.max(max, getIterations(s)), 0);
|
|
8508
|
-
const
|
|
8784
|
+
const scopeNote = specGapScopeFiles.length > 0 ? ` Full-send files remain outside Affected Files: ${specGapScopeFiles.join(", ")}. A BLESS accepts the verdict but does not amend the spec, so --pr will still reject these files: before blessing, add them to the spec's Affected Files (as an ## Amendment) or remove them from the branch.` : "";
|
|
8785
|
+
const reason = `Code review surfaced a spec_gap verdict for task(s): ${specGapIds.join(", ")}. The implementation cannot resolve this \u2014 the root cause is in the spec. ` + scopeNote + `Recovery options (both operate on the full blocked bundle [${taskIds.join(" ")}]):
|
|
8509
8786
|
FIX: amend spec.md with ## Amendment, then: canon run ${taskIds.join(" ")} --reroute
|
|
8510
8787
|
BLESS: canon task accept ${taskIds.join(" ")} code_review --reason "<why>"`;
|
|
8511
8788
|
console.log("");
|
|
@@ -8515,6 +8792,12 @@ async function checkAndRoute(phase, taskIds) {
|
|
|
8515
8792
|
console.log(" The code review found a problem in the spec, not a fixable");
|
|
8516
8793
|
console.log(" implementation bug. Review the findings:");
|
|
8517
8794
|
for (const id of specGapIds) console.log(` tasks/${id}/review.md`);
|
|
8795
|
+
if (specGapScopeFiles.length > 0) {
|
|
8796
|
+
console.log(" Full-send files still outside Affected Files:");
|
|
8797
|
+
for (const file of specGapScopeFiles) console.log(` ${file}`);
|
|
8798
|
+
console.log(" BLESS does not amend the spec, so --pr will still reject these files.");
|
|
8799
|
+
console.log(" Before blessing, add them to Affected Files (## Amendment) or remove them.");
|
|
8800
|
+
}
|
|
8518
8801
|
console.log("");
|
|
8519
8802
|
console.log(" Two recovery options:");
|
|
8520
8803
|
console.log("");
|
|
@@ -8565,8 +8848,8 @@ function checkDeps(taskIds, skipAgentDeps = false) {
|
|
|
8565
8848
|
for (const taskId of taskIds) {
|
|
8566
8849
|
validateTaskId(taskId);
|
|
8567
8850
|
const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", taskId, "status.json");
|
|
8568
|
-
const statusFile = cliArgs.ship &&
|
|
8569
|
-
if (!
|
|
8851
|
+
const statusFile = cliArgs.ship && fs21.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
|
|
8852
|
+
if (!fs21.existsSync(statusFile)) {
|
|
8570
8853
|
die(`No status.json at tasks/${taskId}/status.json \u2014 run canon task new ${taskId} first`);
|
|
8571
8854
|
}
|
|
8572
8855
|
}
|
|
@@ -8608,7 +8891,7 @@ async function main() {
|
|
|
8608
8891
|
let heartbeatStarted = false;
|
|
8609
8892
|
const earlyHeartbeatResolver = (id) => {
|
|
8610
8893
|
const repoRootStatusFile = path20.join(REPO_ROOT2, "tasks", id, "status.json");
|
|
8611
|
-
return path20.dirname(cliArgs.ship &&
|
|
8894
|
+
return path20.dirname(cliArgs.ship && fs21.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
|
|
8612
8895
|
};
|
|
8613
8896
|
if (!cliArgs.ship && !cliArgs.dryRun) {
|
|
8614
8897
|
guardConcurrentRun(cliArgs.taskIds, earlyHeartbeatResolver);
|