canon-ai 3.3.1 → 3.5.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 +39 -0
- package/README.md +1 -1
- package/dist/cli/index.js +68 -90
- package/dist/orchestrator/run-task.js +582 -236
- 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 +14 -0
- package/templates/.claude/agents/code-review-cold.md +5 -2
- package/templates/.claude/skills/canon-pipeline/recovery.md +21 -0
- package/templates/.claude/skills/canon-spec/SKILL.md +10 -1
- package/templates/.claude/skills/canon-spec-review/SKILL.md +7 -3
- package/templates/docs/pipeline-orchestrator.md +37 -32
- package/templates/scripts/docs-refs-check.mjs +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.5.0] — 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **A reviewer that can't reach a verdict now stops the run for a human.** If a code-review lens returns nothing usable twice, the foreman sets `code_review` to `blocked` instead of being pushed to invent a verdict; recover with `canon task reset-code-review` for each bundle member, then `canon run`.
|
|
12
|
+
- **Plans include a short feasibility check.** Pipeline, reroute, and `/canon-spec` XS plans now confirm the code they rely on exists, trace the real call path and callers, and name the input that makes each new test fail without the fix.
|
|
13
|
+
- **`CLAUDE_MODEL_LIGHT` and `CLAUDE_MODEL_STRONG` override each tier's model.** Existing per-phase pins and `CLAUDE_MODEL` still work and take precedence in that order.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Claude defaults are re-baselined for the 5.5 generation.** Spec, plan, and code review run Sonnet `medium` for XS/S, Opus `medium` for M/L, and Opus `high` for XL/delicate; QA runs Sonnet `medium` at every size, and nothing runs at `xhigh`. A model pin now replaces only the model, so a Sonnet pin on M/L review or plan drops from `high` to `medium` effort.
|
|
18
|
+
- **Unattended Claude phases get an explicit unattended-session contract.** Like Codex's headless preamble, it says no one will answer questions, finishing means the artifact is written and the phase command has run, and the phase must not start other phases or reviewers.
|
|
19
|
+
- **The code-review foreman accepts clean lens results and bounds delegation.** A lens's empty result is now valid rather than "a bug in the lens," and the foreman spawns only its two lenses plus one re-spawn.
|
|
20
|
+
- **`/canon-spec-review` no longer requires a task ID.** With no argument it reviews the task under discussion, or asks which one when that's ambiguous.
|
|
21
|
+
|
|
22
|
+
## [3.4.0] — 2026-09-27
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`code_review` catches files outside the spec's Affected Files at pre-flight instead of at `--pr`.** Previously, a helper, fixture, or rebuilt file the spec didn't list could pass `code_review`, only for `--pr`'s base-drift check to reject it. That forced a spec edit and a second `--pr`. The same scope check now runs just before `code_review`. A normal run halts before any reviewer starts, names the file, and offers two fixes: add it to Affected Files and re-run, or reroute to remove it. In a full-send run, the code-review foreman judges each out-of-scope file instead. It classes each one as an appropriate scope expansion, a spec miss, or something that shouldn't have changed, and records the outcome in `review.md`. For the first two, it also adds a `## Amendment (full-send scope)` section to the spec. That heading does not satisfy the human amendment gate on `--reroute`. `--pr`'s base-drift message now says whether the base branch advanced or the file is out of scope. It no longer labels both cases as scope violations.
|
|
27
|
+
- **The anchored code-review lens checks changed code against `docs/patterns.md`.** When the project keeps that file, the lens reads it first, using its Trigger Table if it has one. It reports each violation of a written project rule as a finding that names the rule, not as a nit. The check runs every round and covers only code the diff changes.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **`code_review` re-rounds review only the fix, not the whole diff, when that's safe.** A full review still runs in these cases:
|
|
32
|
+
- Round 1.
|
|
33
|
+
- Every round of an XL or delicate task.
|
|
34
|
+
- Any round where the previous round's record is missing or unusable.
|
|
35
|
+
- The fix touches files outside the previous round's change set.
|
|
36
|
+
- The fix exceeds a size threshold.
|
|
37
|
+
|
|
38
|
+
Otherwise, cold-Codex and the cold-Claude lens review only the changes since the commit the previous round reviewed. The anchored lens keeps the full diff, spec, and handoff. No lens is skipped. `review.md` states each round's scope, base, and reason. Each cold-Codex round now writes its own `tasks/<id>/review-cold-codex-run-N.md` instead of overwriting the previous one. The cold-Claude lens also gains a standing sibling-site sweep: when a diff adds or changes a guard or invariant, it looks for other sites that need the same treatment.
|
|
39
|
+
- **The handoff template's guidance on directory paths matches `docs-refs-check`.** Backticking an existing directory is fine. A directory the task deleted should be described in prose or written as a markdown link.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **The post-implement auto-commit no longer fails when the spec's Affected Files lists a deleted or renamed file.** A path that git had already staged as deleted or renamed away made `git add` fail with "pathspec did not match". All three commit sites now drop paths that are neither tracked nor on disk before staging.
|
|
44
|
+
- **`docs-refs-check` accepts backticked paths to existing directories.** It had reported them as missing files.
|
|
45
|
+
|
|
7
46
|
## [3.3.1] — 2026-09-24
|
|
8
47
|
|
|
9
48
|
### Fixed
|
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ The pipeline supports two tiers:
|
|
|
86
86
|
- **Fast tier** (XS tasks): spec + plan in one Claude session, skip Codex spec review, human gate replaces it.
|
|
87
87
|
- **Full tier** (S / M / L / XL / delicate tasks): every phase runs separately, Codex reviews specs before they reach the human.
|
|
88
88
|
|
|
89
|
-
`delicate: true` (set in `status.json` at task creation) promotes any task to full tier and upgrades the
|
|
89
|
+
`delicate: true` (set in `status.json` at task creation) promotes any task to full tier and upgrades the model and effort for spec, plan, and code review, plus Codex spec review and implementation; QA and the cold-Codex review lens stay on their regular cells. Use it for sensitive surfaces — auth, payments, persistent storage, anything where a regression has unbounded blast radius.
|
|
90
90
|
|
|
91
91
|
A single command runs a task end-to-end:
|
|
92
92
|
|
package/dist/cli/index.js
CHANGED
|
@@ -555,20 +555,6 @@ import path4 from "path";
|
|
|
555
555
|
import { fileURLToPath } from "url";
|
|
556
556
|
var __filename = fileURLToPath(import.meta.url);
|
|
557
557
|
var __dirname = path4.dirname(__filename);
|
|
558
|
-
function parseMaxReviewLoops(raw) {
|
|
559
|
-
if (!raw) return null;
|
|
560
|
-
const trimmed = raw.trim();
|
|
561
|
-
if (!/^-?\d+$/.test(trimmed)) {
|
|
562
|
-
warn(`Invalid MAX_REVIEW_LOOPS value "${raw}"; using the size-aware default.`);
|
|
563
|
-
return null;
|
|
564
|
-
}
|
|
565
|
-
const parsed = Number(trimmed);
|
|
566
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
567
|
-
warn(`Invalid MAX_REVIEW_LOOPS value "${raw}"; using the size-aware default.`);
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
return parsed;
|
|
571
|
-
}
|
|
572
558
|
function resolveRepoRoot() {
|
|
573
559
|
try {
|
|
574
560
|
const result = spawnSync("git", ["rev-parse", "--git-common-dir"], { encoding: "utf8" });
|
|
@@ -601,15 +587,6 @@ function resolveProjectName() {
|
|
|
601
587
|
}
|
|
602
588
|
var config = {
|
|
603
589
|
projectName: resolveProjectName(),
|
|
604
|
-
claudeBudget: process.env.CLAUDE_BUDGET ?? null,
|
|
605
|
-
claudeModelSpec: process.env.CLAUDE_MODEL_SPEC ?? process.env.CLAUDE_MODEL ?? "opus",
|
|
606
|
-
claudeModelPlan: process.env.CLAUDE_MODEL_PLAN ?? process.env.CLAUDE_MODEL ?? "sonnet",
|
|
607
|
-
claudeModelReview: process.env.CLAUDE_MODEL_REVIEW ?? process.env.CLAUDE_MODEL ?? "sonnet",
|
|
608
|
-
claudeModelReviewLarge: process.env.CLAUDE_MODEL_REVIEW_LARGE ?? process.env.CLAUDE_MODEL ?? "opus",
|
|
609
|
-
claudeModelQa: process.env.CLAUDE_MODEL_QA ?? process.env.CLAUDE_MODEL ?? "sonnet",
|
|
610
|
-
codexModelMini: process.env.CODEX_MODEL_MINI ?? process.env.CODEX_MODEL_DEFAULT ?? "gpt-6-luna",
|
|
611
|
-
codexModelFull: process.env.CODEX_MODEL_FULL ?? process.env.CODEX_MODEL_DELICATE ?? "gpt-6-sol",
|
|
612
|
-
maxReviewLoops: parseMaxReviewLoops(process.env.MAX_REVIEW_LOOPS),
|
|
613
590
|
maxContextBytes: Number.parseInt(process.env.MAX_CONTEXT_BYTES ?? String(64 * 1024), 10)
|
|
614
591
|
};
|
|
615
592
|
|
|
@@ -1247,7 +1224,7 @@ function checkTemplates(cwd) {
|
|
|
1247
1224
|
}
|
|
1248
1225
|
function checkCanonVersion(cwd) {
|
|
1249
1226
|
const versionPath = join(cwd, ".canon", "version");
|
|
1250
|
-
const installedVersion = "3.
|
|
1227
|
+
const installedVersion = "3.5.0";
|
|
1251
1228
|
if (!existsSync(versionPath)) {
|
|
1252
1229
|
return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
|
|
1253
1230
|
}
|
|
@@ -1744,7 +1721,7 @@ function initCmd(_args) {
|
|
|
1744
1721
|
}
|
|
1745
1722
|
function writeCanonVersion(cwd) {
|
|
1746
1723
|
const versionPath = join2(cwd, ".canon", "version");
|
|
1747
|
-
const version = "3.
|
|
1724
|
+
const version = "3.5.0";
|
|
1748
1725
|
mkdirSync(dirname2(versionPath), { recursive: true });
|
|
1749
1726
|
writeFileSync(versionPath, version + "\n");
|
|
1750
1727
|
}
|
|
@@ -2696,16 +2673,17 @@ function watchCmd(args2, deps = {}) {
|
|
|
2696
2673
|
|
|
2697
2674
|
// src/task/index.ts
|
|
2698
2675
|
import { spawnSync as spawnSync7 } from "child_process";
|
|
2699
|
-
import
|
|
2676
|
+
import fs14 from "fs";
|
|
2700
2677
|
import path14 from "path";
|
|
2701
2678
|
|
|
2702
2679
|
// src/orchestrator/canon-snapshot.ts
|
|
2703
2680
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
2704
|
-
import
|
|
2681
|
+
import fs10 from "fs";
|
|
2705
2682
|
import path10 from "path";
|
|
2706
2683
|
|
|
2707
2684
|
// src/orchestrator/git.ts
|
|
2708
2685
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2686
|
+
import fs9 from "fs";
|
|
2709
2687
|
import path9 from "path";
|
|
2710
2688
|
|
|
2711
2689
|
// src/orchestrator/worktree.ts
|
|
@@ -2753,7 +2731,7 @@ function isInstalledSourcePath(sourcePath) {
|
|
|
2753
2731
|
return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
|
|
2754
2732
|
}
|
|
2755
2733
|
function resolveCanonVersion(explicit) {
|
|
2756
|
-
return explicit ?? "3.
|
|
2734
|
+
return explicit ?? "3.5.0" ?? "dev";
|
|
2757
2735
|
}
|
|
2758
2736
|
function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
|
|
2759
2737
|
const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
|
|
@@ -2832,24 +2810,24 @@ function applyCanonSnapshot(status, canon) {
|
|
|
2832
2810
|
return next;
|
|
2833
2811
|
}
|
|
2834
2812
|
function refreshCanonSnapshotAtPath(statusFilePath, options = {}) {
|
|
2835
|
-
const status = JSON.parse(
|
|
2813
|
+
const status = JSON.parse(fs10.readFileSync(statusFilePath, "utf8"));
|
|
2836
2814
|
const canon = captureCanonSnapshot(REPO_ROOT, options);
|
|
2837
2815
|
const next = applyCanonSnapshot(status, canon);
|
|
2838
2816
|
const serialized = `${JSON.stringify(next, null, 2)}
|
|
2839
2817
|
`;
|
|
2840
|
-
const current =
|
|
2818
|
+
const current = fs10.readFileSync(statusFilePath, "utf8");
|
|
2841
2819
|
if (current !== serialized) {
|
|
2842
|
-
|
|
2820
|
+
fs10.writeFileSync(statusFilePath, serialized, "utf8");
|
|
2843
2821
|
}
|
|
2844
2822
|
return canon;
|
|
2845
2823
|
}
|
|
2846
2824
|
|
|
2847
2825
|
// src/orchestrator/review-archive.ts
|
|
2848
|
-
import
|
|
2826
|
+
import fs13 from "fs";
|
|
2849
2827
|
import path13 from "path";
|
|
2850
2828
|
|
|
2851
2829
|
// src/task/templates.ts
|
|
2852
|
-
import
|
|
2830
|
+
import fs11 from "fs";
|
|
2853
2831
|
import path11 from "path";
|
|
2854
2832
|
function tasksRoot() {
|
|
2855
2833
|
return process.env.CANON_TASKS_DIR_OVERRIDE ?? "tasks";
|
|
@@ -2862,17 +2840,17 @@ function taskTemplateOverrideRoot() {
|
|
|
2862
2840
|
}
|
|
2863
2841
|
function resolveTaskTemplateSource(basename2) {
|
|
2864
2842
|
const override = path11.join(taskTemplateOverrideRoot(), basename2);
|
|
2865
|
-
if (
|
|
2843
|
+
if (fs11.existsSync(override)) return override;
|
|
2866
2844
|
const managed = path11.join(templatesRoot(), basename2);
|
|
2867
|
-
return
|
|
2845
|
+
return fs11.existsSync(managed) ? managed : null;
|
|
2868
2846
|
}
|
|
2869
2847
|
function renderTaskTemplate(source, taskId, title) {
|
|
2870
|
-
return
|
|
2848
|
+
return fs11.readFileSync(source, "utf8").replaceAll("[TASK-ID]", taskId).replaceAll("[Title]", title);
|
|
2871
2849
|
}
|
|
2872
2850
|
function scaffoldTaskArtifact(taskDir, basename2, taskId, title) {
|
|
2873
2851
|
const source = resolveTaskTemplateSource(basename2);
|
|
2874
2852
|
if (!source) return null;
|
|
2875
|
-
|
|
2853
|
+
fs11.writeFileSync(path11.join(taskDir, basename2), renderTaskTemplate(source, taskId, title), "utf8");
|
|
2876
2854
|
return source;
|
|
2877
2855
|
}
|
|
2878
2856
|
function isPristineTaskArtifact(content, basename2, taskId, title) {
|
|
@@ -2887,7 +2865,7 @@ function isPristineTaskArtifact(content, basename2, taskId, title) {
|
|
|
2887
2865
|
}
|
|
2888
2866
|
|
|
2889
2867
|
// src/orchestrator/validation.ts
|
|
2890
|
-
import
|
|
2868
|
+
import fs12 from "fs";
|
|
2891
2869
|
import path12 from "path";
|
|
2892
2870
|
function computeLatestValidationResults(handoffContent) {
|
|
2893
2871
|
const latest = /* @__PURE__ */ new Map();
|
|
@@ -3019,7 +2997,7 @@ function isTemplateUnfilled(content) {
|
|
|
3019
2997
|
function isDoneMdTemplate(donePath) {
|
|
3020
2998
|
let content;
|
|
3021
2999
|
try {
|
|
3022
|
-
content =
|
|
3000
|
+
content = fs12.readFileSync(donePath, "utf8");
|
|
3023
3001
|
} catch {
|
|
3024
3002
|
return true;
|
|
3025
3003
|
}
|
|
@@ -3058,7 +3036,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3058
3036
|
const artifactPath = path12.join(taskDir, config2.artifactName);
|
|
3059
3037
|
let content;
|
|
3060
3038
|
try {
|
|
3061
|
-
content =
|
|
3039
|
+
content = fs12.readFileSync(artifactPath, "utf8");
|
|
3062
3040
|
} catch {
|
|
3063
3041
|
return { ok: false, reason: `${config2.artifactName} is missing for phase '${phase}'` };
|
|
3064
3042
|
}
|
|
@@ -3070,7 +3048,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3070
3048
|
if (phase === "spec_review" || phase === "plan") {
|
|
3071
3049
|
let statusRaw;
|
|
3072
3050
|
try {
|
|
3073
|
-
statusRaw =
|
|
3051
|
+
statusRaw = fs12.readFileSync(path12.join(taskDir, "status.json"), "utf8");
|
|
3074
3052
|
} catch {
|
|
3075
3053
|
return { ok: false, reason: `cannot determine reroute state for '${phase}': status.json in ${taskDir} is missing or unreadable` };
|
|
3076
3054
|
}
|
|
@@ -3108,7 +3086,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3108
3086
|
const handoffPath = path12.join(taskDir, "handoff.md");
|
|
3109
3087
|
let handoffContent;
|
|
3110
3088
|
try {
|
|
3111
|
-
handoffContent =
|
|
3089
|
+
handoffContent = fs12.readFileSync(handoffPath, "utf8");
|
|
3112
3090
|
} catch {
|
|
3113
3091
|
return { ok: false, reason: `closing human_review requires a handoff.md \u2014 none found in ${taskDir}` };
|
|
3114
3092
|
}
|
|
@@ -3117,7 +3095,7 @@ function checkPhaseGate(taskId, phase, verdict, taskDirOverride) {
|
|
|
3117
3095
|
const donePath = path12.join(taskDir, "done.md");
|
|
3118
3096
|
let doneContent = "";
|
|
3119
3097
|
try {
|
|
3120
|
-
doneContent =
|
|
3098
|
+
doneContent = fs12.readFileSync(donePath, "utf8");
|
|
3121
3099
|
} catch {
|
|
3122
3100
|
}
|
|
3123
3101
|
if (hasHumanPendingWaiver(doneContent)) return { ok: true };
|
|
@@ -3135,7 +3113,7 @@ function parseHandoffChangesRows(taskId) {
|
|
|
3135
3113
|
const handoffPath = path12.join(taskDirFor(taskId), "handoff.md");
|
|
3136
3114
|
let content;
|
|
3137
3115
|
try {
|
|
3138
|
-
content =
|
|
3116
|
+
content = fs12.readFileSync(handoffPath, "utf8");
|
|
3139
3117
|
} catch {
|
|
3140
3118
|
return { files: [], malformed: [] };
|
|
3141
3119
|
}
|
|
@@ -3439,7 +3417,7 @@ var REVIEW_ARCHIVE_PREFIX = "review-prior-";
|
|
|
3439
3417
|
var REVIEW_ARCHIVE_RE = new RegExp(`^${REVIEW_ARCHIVE_PREFIX}(\\d+)\\.md$`);
|
|
3440
3418
|
function newestReviewArchiveNumber(taskDir) {
|
|
3441
3419
|
let newest = 0;
|
|
3442
|
-
for (const name of
|
|
3420
|
+
for (const name of fs13.readdirSync(taskDir)) {
|
|
3443
3421
|
const match = REVIEW_ARCHIVE_RE.exec(name);
|
|
3444
3422
|
if (match) newest = Math.max(newest, Number(match[1]));
|
|
3445
3423
|
}
|
|
@@ -3447,21 +3425,21 @@ function newestReviewArchiveNumber(taskDir) {
|
|
|
3447
3425
|
}
|
|
3448
3426
|
function archivePriorReview(taskDir, options = {}) {
|
|
3449
3427
|
const reviewPath = path13.join(taskDir, "review.md");
|
|
3450
|
-
if (!
|
|
3428
|
+
if (!fs13.existsSync(reviewPath)) return null;
|
|
3451
3429
|
if (options.skipUnfilledTemplate) {
|
|
3452
|
-
const content =
|
|
3430
|
+
const content = fs13.readFileSync(reviewPath, "utf8");
|
|
3453
3431
|
if (isTemplateUnfilled(content)) return null;
|
|
3454
3432
|
if (options.scaffold && isPristineTaskArtifact(content, "review.md", options.scaffold.taskId, options.scaffold.title)) {
|
|
3455
3433
|
return null;
|
|
3456
3434
|
}
|
|
3457
3435
|
}
|
|
3458
3436
|
const archiveName = `${REVIEW_ARCHIVE_PREFIX}${newestReviewArchiveNumber(taskDir) + 1}.md`;
|
|
3459
|
-
|
|
3437
|
+
fs13.renameSync(reviewPath, path13.join(taskDir, archiveName));
|
|
3460
3438
|
return archiveName;
|
|
3461
3439
|
}
|
|
3462
3440
|
function rescaffoldReview(taskDir, identity) {
|
|
3463
3441
|
try {
|
|
3464
|
-
if (
|
|
3442
|
+
if (fs13.existsSync(path13.join(taskDir, "review.md"))) return { outcome: "exists" };
|
|
3465
3443
|
const source = scaffoldTaskArtifact(taskDir, "review.md", identity.taskId, identity.title);
|
|
3466
3444
|
return source ? { outcome: "written", source } : { outcome: "no-template" };
|
|
3467
3445
|
} catch (error) {
|
|
@@ -3534,7 +3512,7 @@ function taskRootForGate(cwd) {
|
|
|
3534
3512
|
}
|
|
3535
3513
|
function readJsonFile(filePath) {
|
|
3536
3514
|
try {
|
|
3537
|
-
return JSON.parse(
|
|
3515
|
+
return JSON.parse(fs14.readFileSync(filePath, "utf8"));
|
|
3538
3516
|
} catch (error) {
|
|
3539
3517
|
const message = error instanceof Error ? error.message : String(error);
|
|
3540
3518
|
throw new Error(`Error: failed to read ${filePath}: ${message}`);
|
|
@@ -3542,9 +3520,9 @@ function readJsonFile(filePath) {
|
|
|
3542
3520
|
}
|
|
3543
3521
|
function writeJsonAtomic(filePath, data) {
|
|
3544
3522
|
const tmpFile = `${filePath}.tmp`;
|
|
3545
|
-
|
|
3523
|
+
fs14.writeFileSync(tmpFile, `${JSON.stringify(data, null, 2)}
|
|
3546
3524
|
`, "utf8");
|
|
3547
|
-
|
|
3525
|
+
fs14.renameSync(tmpFile, filePath);
|
|
3548
3526
|
}
|
|
3549
3527
|
function writeStatusAtomic(filePath, status) {
|
|
3550
3528
|
status.status = deriveTopLevelStatus(status);
|
|
@@ -3585,15 +3563,15 @@ function currentBranchOrEmpty() {
|
|
|
3585
3563
|
}
|
|
3586
3564
|
function listTemplateFiles() {
|
|
3587
3565
|
const root = templatesRoot();
|
|
3588
|
-
if (!
|
|
3566
|
+
if (!fs14.existsSync(root)) {
|
|
3589
3567
|
throw new Error(`Error: templates directory not found at ${root}`);
|
|
3590
3568
|
}
|
|
3591
|
-
return
|
|
3569
|
+
return fs14.readdirSync(root).filter((name) => name.endsWith(".md") || name.endsWith(".json")).sort();
|
|
3592
3570
|
}
|
|
3593
3571
|
function printCreatedTask(taskDir, baseBranch) {
|
|
3594
3572
|
console.log(`Created task: ${taskDir}`);
|
|
3595
3573
|
console.log("Files:");
|
|
3596
|
-
for (const file of
|
|
3574
|
+
for (const file of fs14.readdirSync(taskDir).sort()) {
|
|
3597
3575
|
console.log(file);
|
|
3598
3576
|
}
|
|
3599
3577
|
console.log("");
|
|
@@ -3631,16 +3609,16 @@ function taskNew(args2) {
|
|
|
3631
3609
|
throw new Error("Error: title must be single-line (no embedded newlines).");
|
|
3632
3610
|
}
|
|
3633
3611
|
const taskDir = taskDirFromRoot(id);
|
|
3634
|
-
if (
|
|
3612
|
+
if (fs14.existsSync(taskDir)) {
|
|
3635
3613
|
throw new Error(`Error: Task directory ${taskDir} already exists.`);
|
|
3636
3614
|
}
|
|
3637
3615
|
if (!baseBranch) {
|
|
3638
3616
|
baseBranch = currentBranchOrEmpty() || "main";
|
|
3639
3617
|
}
|
|
3640
|
-
|
|
3618
|
+
fs14.mkdirSync(taskDir, { recursive: true });
|
|
3641
3619
|
for (const basename2 of listTemplateFiles()) {
|
|
3642
3620
|
const source = resolveTaskTemplateSource(basename2) ?? path14.join(templatesRoot(), basename2);
|
|
3643
|
-
|
|
3621
|
+
fs14.writeFileSync(path14.join(taskDir, basename2), renderTaskTemplate(source, id, title), "utf8");
|
|
3644
3622
|
}
|
|
3645
3623
|
const statusPath = path14.join(taskDir, "status.json");
|
|
3646
3624
|
const status = readJsonFile(statusPath);
|
|
@@ -3663,13 +3641,13 @@ function derivePhase(status) {
|
|
|
3663
3641
|
}
|
|
3664
3642
|
function taskList() {
|
|
3665
3643
|
const root = tasksRoot();
|
|
3666
|
-
if (!
|
|
3644
|
+
if (!fs14.existsSync(root)) {
|
|
3667
3645
|
console.log("No tasks found.");
|
|
3668
3646
|
return;
|
|
3669
3647
|
}
|
|
3670
3648
|
const rows = [];
|
|
3671
3649
|
let invalidCount = 0;
|
|
3672
|
-
for (const entry of
|
|
3650
|
+
for (const entry of fs14.readdirSync(root).sort()) {
|
|
3673
3651
|
if (entry === "_archive" || entry === "_templates") continue;
|
|
3674
3652
|
if (isOrphanedWorktreeState(entry)) {
|
|
3675
3653
|
invalidCount += 1;
|
|
@@ -3687,7 +3665,7 @@ function taskList() {
|
|
|
3687
3665
|
continue;
|
|
3688
3666
|
}
|
|
3689
3667
|
const statusPath = path14.join(taskDirForCwd(process.cwd(), entry), "status.json");
|
|
3690
|
-
if (!
|
|
3668
|
+
if (!fs14.existsSync(statusPath)) continue;
|
|
3691
3669
|
try {
|
|
3692
3670
|
const status = readJsonFile(statusPath);
|
|
3693
3671
|
const phase = derivePhase(status);
|
|
@@ -3724,7 +3702,7 @@ function taskStatus(id) {
|
|
|
3724
3702
|
validateTaskId(id);
|
|
3725
3703
|
const cwd = resolveTaskCwd(id);
|
|
3726
3704
|
const statusPath = taskStatusFileForCwd(cwd, id);
|
|
3727
|
-
if (!
|
|
3705
|
+
if (!fs14.existsSync(statusPath)) {
|
|
3728
3706
|
throw new Error(`Error: No status.json found for task ${id}`);
|
|
3729
3707
|
}
|
|
3730
3708
|
const status = readJsonFile(statusPath);
|
|
@@ -3797,7 +3775,7 @@ function taskPhase(id, phaseArg, statusArg, verdictArg) {
|
|
|
3797
3775
|
assertValidVerdict(phaseArg, verdictArg);
|
|
3798
3776
|
const taskCwd = resolveTaskCwd(id);
|
|
3799
3777
|
const statusPath = taskStatusFileForCwd(taskCwd, id);
|
|
3800
|
-
if (!
|
|
3778
|
+
if (!fs14.existsSync(statusPath)) {
|
|
3801
3779
|
throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
|
|
3802
3780
|
}
|
|
3803
3781
|
const status = readJsonFile(statusPath);
|
|
@@ -3863,7 +3841,7 @@ function taskAccept(ids, phaseArg, options = {}) {
|
|
|
3863
3841
|
for (const id of ids) {
|
|
3864
3842
|
const taskCwd = resolveTaskCwd(id);
|
|
3865
3843
|
const statusPath = taskStatusFileForCwd(taskCwd, id);
|
|
3866
|
-
if (!
|
|
3844
|
+
if (!fs14.existsSync(statusPath)) {
|
|
3867
3845
|
throw new Error(`Error: No status.json found for task ${id} (looked in ${taskDirForCwd(taskCwd, id)}/)`);
|
|
3868
3846
|
}
|
|
3869
3847
|
const status = readJsonFile(statusPath);
|
|
@@ -3965,7 +3943,7 @@ function taskAccept(ids, phaseArg, options = {}) {
|
|
|
3965
3943
|
const originalSnapshots2 = /* @__PURE__ */ new Map();
|
|
3966
3944
|
for (const ctx of ctxByTask.values()) {
|
|
3967
3945
|
try {
|
|
3968
|
-
originalSnapshots2.set(ctx.statusPath,
|
|
3946
|
+
originalSnapshots2.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
|
|
3969
3947
|
} catch (error) {
|
|
3970
3948
|
const message = error instanceof Error ? error.message : String(error);
|
|
3971
3949
|
throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
|
|
@@ -4002,8 +3980,8 @@ function taskAccept(ids, phaseArg, options = {}) {
|
|
|
4002
3980
|
if (original === void 0) continue;
|
|
4003
3981
|
try {
|
|
4004
3982
|
const tmpFile = `${filePath}.rollback.tmp`;
|
|
4005
|
-
|
|
4006
|
-
|
|
3983
|
+
fs14.writeFileSync(tmpFile, original, "utf8");
|
|
3984
|
+
fs14.renameSync(tmpFile, filePath);
|
|
4007
3985
|
} catch (rollbackErr) {
|
|
4008
3986
|
const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
4009
3987
|
rollbackErrors.push(` ${filePath}: ${message}`);
|
|
@@ -4027,12 +4005,12 @@ ${rollbackErrors.join("\n")}`
|
|
|
4027
4005
|
const bundleNote = ids.length > 1 ? ` Bundle: ${ids.join(", ")}.` : "";
|
|
4028
4006
|
const noteLine = `[${today()}] Operator accepted ${phaseArg} via \`canon task accept\` \u2014 ${sanctioned ? "sanctioned (agent verdict overridden)" : "unblocked (advancing verdict preserved)"}. Reason: ${reason}.${bundleNote}`;
|
|
4029
4007
|
try {
|
|
4030
|
-
if (
|
|
4031
|
-
|
|
4008
|
+
if (fs14.existsSync(notesPath)) {
|
|
4009
|
+
fs14.appendFileSync(notesPath, `
|
|
4032
4010
|
${noteLine}
|
|
4033
4011
|
`, "utf8");
|
|
4034
4012
|
} else {
|
|
4035
|
-
|
|
4013
|
+
fs14.writeFileSync(notesPath, `${noteLine}
|
|
4036
4014
|
`, "utf8");
|
|
4037
4015
|
}
|
|
4038
4016
|
} catch (error) {
|
|
@@ -4155,7 +4133,7 @@ ${noteLine}
|
|
|
4155
4133
|
const originalSnapshots = /* @__PURE__ */ new Map();
|
|
4156
4134
|
for (const ctx of ctxByTask.values()) {
|
|
4157
4135
|
try {
|
|
4158
|
-
originalSnapshots.set(ctx.statusPath,
|
|
4136
|
+
originalSnapshots.set(ctx.statusPath, fs14.readFileSync(ctx.statusPath, "utf8"));
|
|
4159
4137
|
} catch (error) {
|
|
4160
4138
|
const message = error instanceof Error ? error.message : String(error);
|
|
4161
4139
|
throw new Error(`Error: failed to read ${ctx.statusPath} for rollback snapshot: ${message}`);
|
|
@@ -4180,8 +4158,8 @@ ${noteLine}
|
|
|
4180
4158
|
if (original === void 0) continue;
|
|
4181
4159
|
try {
|
|
4182
4160
|
const tmpFile = `${filePath}.rollback.tmp`;
|
|
4183
|
-
|
|
4184
|
-
|
|
4161
|
+
fs14.writeFileSync(tmpFile, original, "utf8");
|
|
4162
|
+
fs14.renameSync(tmpFile, filePath);
|
|
4185
4163
|
} catch (rollbackErr) {
|
|
4186
4164
|
const message = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
4187
4165
|
rollbackErrors.push(` ${filePath}: ${message}`);
|
|
@@ -4203,12 +4181,12 @@ ${noteLine}
|
|
|
4203
4181
|
const notesPath = path14.join(taskDirForCwd(ctx.taskCwd, ctx.id), "notes.md");
|
|
4204
4182
|
const noteLine = `[${today()}] Operator accepted implement phase via \`canon task accept\` \u2014 auto-commit will be skipped.${options.force ? " (--force)" : ""}`;
|
|
4205
4183
|
try {
|
|
4206
|
-
if (
|
|
4207
|
-
|
|
4184
|
+
if (fs14.existsSync(notesPath)) {
|
|
4185
|
+
fs14.appendFileSync(notesPath, `
|
|
4208
4186
|
${noteLine}
|
|
4209
4187
|
`, "utf8");
|
|
4210
4188
|
} else {
|
|
4211
|
-
|
|
4189
|
+
fs14.writeFileSync(notesPath, `${noteLine}
|
|
4212
4190
|
`, "utf8");
|
|
4213
4191
|
}
|
|
4214
4192
|
} catch (error) {
|
|
@@ -4228,14 +4206,14 @@ function taskResetSpecReview(id) {
|
|
|
4228
4206
|
const taskCwd = resolveTaskCwd(id);
|
|
4229
4207
|
const taskDir = taskDirForCwd(taskCwd, id);
|
|
4230
4208
|
const statusPath = path14.join(taskDir, "status.json");
|
|
4231
|
-
if (!
|
|
4209
|
+
if (!fs14.existsSync(statusPath)) {
|
|
4232
4210
|
throw new Error(`Error: no status.json at ${statusPath}`);
|
|
4233
4211
|
}
|
|
4234
4212
|
const reviewPath = path14.join(taskDir, "spec-review.md");
|
|
4235
|
-
if (
|
|
4213
|
+
if (fs14.existsSync(reviewPath)) {
|
|
4236
4214
|
let n = 1;
|
|
4237
|
-
while (
|
|
4238
|
-
|
|
4215
|
+
while (fs14.existsSync(path14.join(taskDir, `spec-review-prior-${n}.md`))) n += 1;
|
|
4216
|
+
fs14.renameSync(reviewPath, path14.join(taskDir, `spec-review-prior-${n}.md`));
|
|
4239
4217
|
console.log(`Archived prior spec-review.md \u2192 spec-review-prior-${n}.md`);
|
|
4240
4218
|
}
|
|
4241
4219
|
const status = readJsonFile(statusPath);
|
|
@@ -4259,7 +4237,7 @@ function taskResetCodeReview(id) {
|
|
|
4259
4237
|
const taskCwd = resolveTaskCwd(id);
|
|
4260
4238
|
const taskDir = taskDirForCwd(taskCwd, id);
|
|
4261
4239
|
const statusPath = path14.join(taskDir, "status.json");
|
|
4262
|
-
if (!
|
|
4240
|
+
if (!fs14.existsSync(statusPath)) {
|
|
4263
4241
|
throw new Error(`Error: no status.json at ${statusPath}`);
|
|
4264
4242
|
}
|
|
4265
4243
|
const status = readJsonFile(statusPath);
|
|
@@ -4345,12 +4323,12 @@ function resolveMainCheckoutRoot() {
|
|
|
4345
4323
|
}
|
|
4346
4324
|
function safeRealpath(target) {
|
|
4347
4325
|
try {
|
|
4348
|
-
return
|
|
4326
|
+
return fs14.realpathSync(target);
|
|
4349
4327
|
} catch {
|
|
4350
4328
|
const parent = path14.dirname(target);
|
|
4351
4329
|
if (parent === target) return target;
|
|
4352
4330
|
try {
|
|
4353
|
-
return path14.join(
|
|
4331
|
+
return path14.join(fs14.realpathSync(parent), path14.basename(target));
|
|
4354
4332
|
} catch {
|
|
4355
4333
|
return target;
|
|
4356
4334
|
}
|
|
@@ -4458,12 +4436,12 @@ function taskPostMergeSync(branchArg) {
|
|
|
4458
4436
|
}
|
|
4459
4437
|
function nudgeShippableTasks() {
|
|
4460
4438
|
const root = tasksRoot();
|
|
4461
|
-
if (!
|
|
4439
|
+
if (!fs14.existsSync(root)) return;
|
|
4462
4440
|
const shippable = [];
|
|
4463
|
-
for (const entry of
|
|
4441
|
+
for (const entry of fs14.readdirSync(root).sort()) {
|
|
4464
4442
|
if (entry === "_archive" || entry.startsWith("_")) continue;
|
|
4465
4443
|
const statusPath = path14.join(root, entry, "status.json");
|
|
4466
|
-
if (!
|
|
4444
|
+
if (!fs14.existsSync(statusPath)) continue;
|
|
4467
4445
|
let status;
|
|
4468
4446
|
try {
|
|
4469
4447
|
status = readJsonFile(statusPath);
|
|
@@ -4538,7 +4516,7 @@ function taskSet(args2) {
|
|
|
4538
4516
|
validateTaskId(id);
|
|
4539
4517
|
const taskCwd = resolveTaskCwd(id);
|
|
4540
4518
|
const statusPath = taskStatusFileForCwd(taskCwd, id);
|
|
4541
|
-
if (!
|
|
4519
|
+
if (!fs14.existsSync(statusPath)) {
|
|
4542
4520
|
throw new Error(`Error: No status.json found for task ${id}`);
|
|
4543
4521
|
}
|
|
4544
4522
|
const status = readJsonFile(statusPath);
|
|
@@ -4879,7 +4857,7 @@ function currentPinFromManifest(manifest) {
|
|
|
4879
4857
|
return "unknown";
|
|
4880
4858
|
}
|
|
4881
4859
|
function bakedVersion() {
|
|
4882
|
-
return "3.
|
|
4860
|
+
return "3.5.0";
|
|
4883
4861
|
}
|
|
4884
4862
|
function ownPackageName(pkgDir) {
|
|
4885
4863
|
try {
|
|
@@ -5355,7 +5333,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
|
|
|
5355
5333
|
cutoverWarnings.push(docsRefsCheckRel);
|
|
5356
5334
|
}
|
|
5357
5335
|
const versionPath = join5(cwd, ".canon", "version");
|
|
5358
|
-
const newVersion = "3.
|
|
5336
|
+
const newVersion = "3.5.0";
|
|
5359
5337
|
const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
|
|
5360
5338
|
if (currentVersion !== newVersion) {
|
|
5361
5339
|
pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
|
|
@@ -5663,7 +5641,7 @@ Global:
|
|
|
5663
5641
|
`);
|
|
5664
5642
|
}
|
|
5665
5643
|
function printVersion() {
|
|
5666
|
-
console.log("3.
|
|
5644
|
+
console.log("3.5.0");
|
|
5667
5645
|
}
|
|
5668
5646
|
switch (command) {
|
|
5669
5647
|
case "doctor":
|