canon-ai 3.9.0 → 3.10.1
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 +16 -1
- package/dist/cli/index.js +35 -30
- package/dist/orchestrator/run-task.js +365 -151
- package/package.json +1 -1
- package/templates/.canon/templates/notes.md +1 -1
- package/templates/docs/lessons-learned.md +3 -3
- package/templates/docs/patterns.md +1 -1
- package/templates/docs/pipeline-orchestrator.md +6 -4
- package/templates/docs/task-quality-log.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
> Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). canon-ai uses SemVer per [`docs/decisions.md`](docs/decisions.md).
|
|
4
4
|
|
|
5
|
-
## [
|
|
5
|
+
## [3.10.1] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A task's lessons no longer bring a stray heading into the shared lessons log.** `canon run --ship` drops any `#` or `##` title an agent puts at the top of a task's lessons file before folding it into `docs/lessons-learned.md`, and the QA prompt now says that file holds only `###` entries.
|
|
10
|
+
|
|
11
|
+
## [3.10.0] — 2026-10-17
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Parallel tasks no longer conflict on the shared telemetry logs.** While a task runs, its metrics rows, quality-log row and QA lessons go to its own files in its task folder. `canon run --ship` folds them into `docs/pipeline-invocations.md`, `docs/task-quality-log.md` and `docs/lessons-learned.md` in the archive commit it already makes on the base branch. Two task PRs no longer touch those three files, so they no longer conflict there, on GitHub or locally. Tasks started on an older canon still merge their log edits the old way.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`--pr` warns when a task branch still edits a shared telemetry log.** The warning names each file and doesn't block the PR.
|
|
20
|
+
- **A `--ship` failure after the fold says where the folded rows are.** If the archive move, commit or push fails after the logs were folded, the message names the affected logs, says the task's fragments are already gone, says not to reset those docs, and points to the recovery steps in the pipeline reference.
|
|
6
21
|
|
|
7
22
|
## [3.9.0] — 2026-10-16
|
|
8
23
|
|
package/dist/cli/index.js
CHANGED
|
@@ -827,12 +827,12 @@ function splitTableRowCells(line) {
|
|
|
827
827
|
continue;
|
|
828
828
|
}
|
|
829
829
|
if (backslashes > 0) {
|
|
830
|
-
cell += "\\".repeat(backslashes);
|
|
830
|
+
cell += "\\".repeat(Math.ceil(backslashes / 2));
|
|
831
831
|
backslashes = 0;
|
|
832
832
|
}
|
|
833
833
|
cell += char;
|
|
834
834
|
}
|
|
835
|
-
if (backslashes > 0) cell += "\\".repeat(backslashes);
|
|
835
|
+
if (backslashes > 0) cell += "\\".repeat(Math.ceil(backslashes / 2));
|
|
836
836
|
cells.push(cell);
|
|
837
837
|
const innerCells = cells.slice(
|
|
838
838
|
(cells[0] ?? "").trim() === "" ? 1 : 0,
|
|
@@ -960,6 +960,26 @@ function writeFileAtomic(filePath, content) {
|
|
|
960
960
|
}
|
|
961
961
|
}
|
|
962
962
|
}
|
|
963
|
+
function replaceTaskRow(lines, located, taskId, buildRow) {
|
|
964
|
+
const logRows = parseLogRows(
|
|
965
|
+
lines,
|
|
966
|
+
located.headerCells,
|
|
967
|
+
located.dataStart,
|
|
968
|
+
located.dataEnd
|
|
969
|
+
);
|
|
970
|
+
const strayRows = parseStrayRows(lines, located.headerCells);
|
|
971
|
+
const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === taskId);
|
|
972
|
+
const finalRow = buildRow(taskRows);
|
|
973
|
+
const rendered = renderRowLine(located.headerCells, finalRow);
|
|
974
|
+
const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
|
|
975
|
+
const updatedLines = [];
|
|
976
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
977
|
+
if (index === located.dataEnd) updatedLines.push(rendered);
|
|
978
|
+
if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
|
|
979
|
+
}
|
|
980
|
+
if (located.dataEnd >= lines.length) updatedLines.push(rendered);
|
|
981
|
+
return updatedLines;
|
|
982
|
+
}
|
|
963
983
|
function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
|
|
964
984
|
try {
|
|
965
985
|
let content;
|
|
@@ -982,24 +1002,7 @@ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
|
|
|
982
1002
|
);
|
|
983
1003
|
return;
|
|
984
1004
|
}
|
|
985
|
-
const
|
|
986
|
-
lines,
|
|
987
|
-
located.headerCells,
|
|
988
|
-
located.dataStart,
|
|
989
|
-
located.dataEnd
|
|
990
|
-
);
|
|
991
|
-
const strayRows = parseStrayRows(lines, located.headerCells);
|
|
992
|
-
const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === derived.taskId);
|
|
993
|
-
const reconciled = reconcileHistory(taskRows, located.headerCells);
|
|
994
|
-
const finalRow = buildFinalRow(located.headerCells, derived, reconciled, qaSupplied);
|
|
995
|
-
const rendered = renderRowLine(located.headerCells, finalRow);
|
|
996
|
-
const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
|
|
997
|
-
const updatedLines = [];
|
|
998
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
999
|
-
if (index === located.dataEnd) updatedLines.push(rendered);
|
|
1000
|
-
if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
|
|
1001
|
-
}
|
|
1002
|
-
if (located.dataEnd >= lines.length) updatedLines.push(rendered);
|
|
1005
|
+
const updatedLines = replaceTaskRow(lines, located, derived.taskId, (taskRows) => buildFinalRow(located.headerCells, derived, reconcileHistory(taskRows, located.headerCells), qaSupplied));
|
|
1003
1006
|
writeFileAtomic(logFilePath, updatedLines.join("\n"));
|
|
1004
1007
|
} catch (error) {
|
|
1005
1008
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1019,15 +1022,17 @@ function parseQualityLogJudgmentBlock(doneMdContent) {
|
|
|
1019
1022
|
}
|
|
1020
1023
|
return result;
|
|
1021
1024
|
}
|
|
1022
|
-
function writeQualityLogForTask(taskId,
|
|
1025
|
+
function writeQualityLogForTask(taskId, _activeCwd, donePath, status) {
|
|
1023
1026
|
try {
|
|
1024
1027
|
let doneContent = "";
|
|
1025
1028
|
try {
|
|
1026
1029
|
doneContent = fs8.readFileSync(donePath, "utf8");
|
|
1027
1030
|
} catch {
|
|
1028
1031
|
}
|
|
1032
|
+
const target = process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path7.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path7.join(path7.dirname(donePath), "quality-log.md");
|
|
1033
|
+
if (!process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) fs8.mkdirSync(path7.dirname(target), { recursive: true });
|
|
1029
1034
|
upsertQualityLogRow(
|
|
1030
|
-
|
|
1035
|
+
target,
|
|
1031
1036
|
{
|
|
1032
1037
|
taskId,
|
|
1033
1038
|
taskSize: status.task_size,
|
|
@@ -1438,7 +1443,7 @@ function checkTemplates(cwd) {
|
|
|
1438
1443
|
}
|
|
1439
1444
|
function checkCanonVersion(cwd) {
|
|
1440
1445
|
const versionPath = join(cwd, ".canon", "version");
|
|
1441
|
-
const installedVersion = "3.
|
|
1446
|
+
const installedVersion = "3.10.1";
|
|
1442
1447
|
if (!existsSync(versionPath)) {
|
|
1443
1448
|
return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
|
|
1444
1449
|
}
|
|
@@ -1485,13 +1490,13 @@ function checkQualityLog(cwd) {
|
|
|
1485
1490
|
return {
|
|
1486
1491
|
label,
|
|
1487
1492
|
status: "warn",
|
|
1488
|
-
detail: `parent directory of ${logPath} does not exist \u2014
|
|
1493
|
+
detail: `parent directory of ${logPath} does not exist \u2014 a missing docs directory usually means the scaffold is incomplete (re-run \`canon init\`); \`canon run --ship\` will create it when folding a task's quality-log fragment`
|
|
1489
1494
|
};
|
|
1490
1495
|
}
|
|
1491
1496
|
return {
|
|
1492
1497
|
label,
|
|
1493
1498
|
status: "pass",
|
|
1494
|
-
detail: "not present \u2014
|
|
1499
|
+
detail: "not present \u2014 created when `canon run --ship` folds a task's quality-log fragment"
|
|
1495
1500
|
};
|
|
1496
1501
|
}
|
|
1497
1502
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1963,7 +1968,7 @@ function initCmd(_args) {
|
|
|
1963
1968
|
}
|
|
1964
1969
|
function writeCanonVersion(cwd) {
|
|
1965
1970
|
const versionPath = join2(cwd, ".canon", "version");
|
|
1966
|
-
const version = "3.
|
|
1971
|
+
const version = "3.10.1";
|
|
1967
1972
|
mkdirSync(dirname2(versionPath), { recursive: true });
|
|
1968
1973
|
writeFileSync(versionPath, version + "\n");
|
|
1969
1974
|
}
|
|
@@ -2927,7 +2932,7 @@ function isInstalledSourcePath(sourcePath) {
|
|
|
2927
2932
|
return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
|
|
2928
2933
|
}
|
|
2929
2934
|
function resolveCanonVersion(explicit) {
|
|
2930
|
-
return explicit ?? "3.
|
|
2935
|
+
return explicit ?? "3.10.1" ?? "dev";
|
|
2931
2936
|
}
|
|
2932
2937
|
function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
|
|
2933
2938
|
const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
|
|
@@ -5089,7 +5094,7 @@ function currentPinFromManifest(manifest) {
|
|
|
5089
5094
|
return "unknown";
|
|
5090
5095
|
}
|
|
5091
5096
|
function bakedVersion() {
|
|
5092
|
-
return "3.
|
|
5097
|
+
return "3.10.1";
|
|
5093
5098
|
}
|
|
5094
5099
|
function ownPackageName(pkgDir) {
|
|
5095
5100
|
try {
|
|
@@ -5566,7 +5571,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
|
|
|
5566
5571
|
cutoverWarnings.push(docsRefsCheckRel);
|
|
5567
5572
|
}
|
|
5568
5573
|
const versionPath = join5(cwd, ".canon", "version");
|
|
5569
|
-
const newVersion = "3.
|
|
5574
|
+
const newVersion = "3.10.1";
|
|
5570
5575
|
const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
|
|
5571
5576
|
if (currentVersion !== newVersion) {
|
|
5572
5577
|
pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
|
|
@@ -5875,7 +5880,7 @@ Global:
|
|
|
5875
5880
|
`);
|
|
5876
5881
|
}
|
|
5877
5882
|
function printVersion() {
|
|
5878
|
-
console.log("3.
|
|
5883
|
+
console.log("3.10.1");
|
|
5879
5884
|
}
|
|
5880
5885
|
switch (command) {
|
|
5881
5886
|
case "doctor":
|
|
@@ -1841,9 +1841,9 @@ function resolveDocsFreshness(config3, diff) {
|
|
|
1841
1841
|
|
|
1842
1842
|
// src/orchestrator/main.ts
|
|
1843
1843
|
import { spawnSync as spawnSync6 } from "child_process";
|
|
1844
|
-
import
|
|
1844
|
+
import fs23 from "fs";
|
|
1845
1845
|
import os from "os";
|
|
1846
|
-
import
|
|
1846
|
+
import path22 from "path";
|
|
1847
1847
|
|
|
1848
1848
|
// src/orchestrator/phases/code-review.ts
|
|
1849
1849
|
import fs17 from "fs";
|
|
@@ -2104,31 +2104,45 @@ import { spawn as spawn2 } from "child_process";
|
|
|
2104
2104
|
// src/orchestrator/metrics.ts
|
|
2105
2105
|
import fs8 from "fs";
|
|
2106
2106
|
import path7 from "path";
|
|
2107
|
-
|
|
2108
|
-
|
|
2107
|
+
var METRICS_SKELETON = [
|
|
2108
|
+
"# Workflow Metrics",
|
|
2109
|
+
"",
|
|
2110
|
+
"> Auto-logged by canon's orchestrator. One row per agent invocation.",
|
|
2111
|
+
"> Tokens: per-invocation total (input + cache + output). Parsed from the agent's structured output \u2014 `claude -p --output-format stream-json` for Claude, `codex exec --json` for Codex. Interactive-mode invocations are not tracked.",
|
|
2112
|
+
"",
|
|
2113
|
+
"| Timestamp | Task | Phase | Agent | Model | Iter | Duration | Tokens | Status |",
|
|
2114
|
+
"|---|---|---|---|---|---|---|---|---|",
|
|
2115
|
+
""
|
|
2116
|
+
].join("\n");
|
|
2117
|
+
function getMetricsFile(taskLabel, activeCwd) {
|
|
2118
|
+
return process.env.CANON_METRICS_FILE_OVERRIDE ? path7.resolve(process.env.CANON_METRICS_FILE_OVERRIDE) : path7.join(
|
|
2119
|
+
process.env.CANON_TASKS_DIR_OVERRIDE ?? path7.join(activeCwd ?? REPO_ROOT, "tasks"),
|
|
2120
|
+
taskLabel.split("+")[0],
|
|
2121
|
+
"metrics.md"
|
|
2122
|
+
);
|
|
2123
|
+
}
|
|
2124
|
+
function getSharedMetricsFile(repoRoot) {
|
|
2125
|
+
return path7.join(repoRoot, "docs/pipeline-invocations.md");
|
|
2109
2126
|
}
|
|
2110
2127
|
function recordMetric(entry) {
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
const safeCell = (v) => v.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
2125
|
-
const dur = (entry.durationMs / 1e3).toFixed(1) + "s";
|
|
2126
|
-
const tok = entry.tokens != null ? String(entry.tokens) : "-";
|
|
2127
|
-
fs8.appendFileSync(
|
|
2128
|
-
metricsFile,
|
|
2129
|
-
`| ${(/* @__PURE__ */ new Date()).toISOString()} | ${entry.taskId} | ${entry.phase} | ${entry.agent} | ${safeCell(entry.model)} | ${entry.iteration ?? "-"} | ${dur} | ${tok} | ${entry.status} |
|
|
2128
|
+
try {
|
|
2129
|
+
if (!process.env.CANON_METRICS_FILE_OVERRIDE && !entry.taskId.split("+")[0]) {
|
|
2130
|
+
throw new Error("empty task label");
|
|
2131
|
+
}
|
|
2132
|
+
const metricsFile = getMetricsFile(entry.taskId, entry.activeCwd);
|
|
2133
|
+
if (!process.env.CANON_METRICS_FILE_OVERRIDE) fs8.mkdirSync(path7.dirname(metricsFile), { recursive: true });
|
|
2134
|
+
if (!fs8.existsSync(metricsFile)) fs8.writeFileSync(metricsFile, METRICS_SKELETON);
|
|
2135
|
+
const safeCell = (v) => v.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
2136
|
+
const dur = (entry.durationMs / 1e3).toFixed(1) + "s";
|
|
2137
|
+
const tok = entry.tokens != null ? String(entry.tokens) : "-";
|
|
2138
|
+
fs8.appendFileSync(
|
|
2139
|
+
metricsFile,
|
|
2140
|
+
`| ${(/* @__PURE__ */ new Date()).toISOString()} | ${entry.taskId} | ${entry.phase} | ${entry.agent} | ${safeCell(entry.model)} | ${entry.iteration ?? "-"} | ${dur} | ${tok} | ${entry.status} |
|
|
2130
2141
|
`
|
|
2131
|
-
|
|
2142
|
+
);
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
warn(`metrics: could not record ${entry.phase} row for '${entry.taskId}': ${error instanceof Error ? error.message : String(error)}`);
|
|
2145
|
+
}
|
|
2132
2146
|
}
|
|
2133
2147
|
|
|
2134
2148
|
// src/orchestrator/prompts/helpers.ts
|
|
@@ -4983,7 +4997,7 @@ var plan_reroute_default = "You are updating the implementation plan for {{taskS
|
|
|
4983
4997
|
var plan_default = "You are writing implementation plans for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\n{{{verdictLines}}}\n\nFor each task, read tasks/<id>/spec.md and tasks/<id>/spec-review.md. Address any `changes_requested` items before writing the plan. If the verdict is `approved_with_nits`, incorporate the nits into the plan \u2014 they don't require spec changes but should inform implementation decisions.\n\nWrite tasks/<id>/plan.md for each task with ordered implementation steps. Reference specific files, existing patterns, and code examples from the codebase. Codex implements directly from this plan.\n\n{{{feasibilityCheck}}}\n\nIf you encounter spec gaps, append to tasks/<id>/notes.md (prefix: [plan]).\n\nWhen done, run:\n{{{phaseCommands}}}\n";
|
|
4984
4998
|
|
|
4985
4999
|
// src/orchestrator/prompts/templates/qa.md
|
|
4986
|
-
var qa_default = 'You are writing QA summaries for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\nTasks:\n{{{taskLines}}}\n\nFor each task:\n1. **Use the Write tool** to create tasks/<id>/done.md \u2014 plain-English summary for the human. Include: what changed, files changed, how to test, test results, human verification required, decisions made, open questions.\n \u26A0\uFE0F CRITICAL: Use the `Write` tool \u2014 do NOT simply output the done.md content as text in your response. Content in your chat reply does not get saved to disk. The pipeline validates that done.md contains real content (not the template) before advancing. Write the file.\n2. Read the latest `## Validation Outcomes` table in `tasks/<id>/handoff.md`, including any later iteration `### Re-run validation` tables. If any check\'s latest result is `human_pending`, include a **Human Verification Required** section in done.md that lists each pending check and its Notes. If none remain, write `None.` in that section. Do not hide `human_pending` checks inside the generic Test Results table.\n - If the human chooses to waive or defer a pending check later, the waiver line in done.md must begin with `Acknowledged:`. The `human_review` gate only treats that explicit prefix as a waiver.\n - Preserve `deferred_by_spec` rows in Test Results with the spec citation from Notes; do not translate them to `Pass`.\n3. Include a **Proposed Changelog** section in done.md \u2014 the changelog **entry text only**. Do **NOT** include a version number, a "Proposed version" line, or a bump tier (Patch/Minor/Major): the version is decided at the release step (`/canon-changelog` + human), never by QA.\n - **Canon release rules (non-negotiable)**: (1) Agents do not bump versions or land changelog edits without explicit scope authorization. (2) The QA step proposes a draft changelog entry text only \u2014 not the version number. (3) Changelog + version bump are committed separately from code changes (when a project versions its releases). (4) No major versioning surprises: if a task introduces a breaking change the spec didn\'t flag, raise it before shipping.\n - Read `docs/decisions.md` \xA7"Versioning and release policy" for this project\'s changelog **scope** (which changes warrant an entry). The bump tier / SemVer interpretation is the release step\'s call, not QA\'s \u2014 do not propose one.\n - If CHANGELOG.md exists, read the top of it (the most recent version section) to calibrate on scope and voice.\n - Apply the "would a user notice" test to every candidate bullet (or the project\'s equivalent scope test): if a candidate falls outside the project\'s defined changelog scope, omit it. If a task is entirely out of scope, say so explicitly ("no user-facing change \u2014 omit from changelog") rather than inventing a bullet.\n - Implementation mechanics belong in the "What Changed" section above \u2014 not in the proposed changelog.\n The human finalizes the changelog entry.\n4. **For single tasks only \u2014 use the Write tool** to create `tasks/<id>/pr-body.md` \u2014 the outward-facing PR body draft for `--pr`. Write it as if a human wrote it after doing the work.\n {{#prTemplate}}\n The repo has this PR template. Fill every section with specifics from what shipped. Keep the headings; replace every placeholder:\n\n {{{prTemplate}}}\n {{/prTemplate}}\n {{^prTemplate}}\n No PR template found. Use this structure:\n\n ## Summary\n 1\u20133 bullets: what changed and why.\n\n ## Changes\n Key files or areas touched, described for a reviewer.\n\n ## How to Test\n Steps a reviewer can follow to verify the change.\n\n ## Notes for Reviewer\n Any context, caveats, or follow-up items.\n {{/prTemplate}}\n \u26A0\uFE0F Write as the human engineer who did the work \u2014 not as the AI or tool that produced it (Claude, Codex, canon, an LLM). \u2705 e.g. "Fix the pagination off-by-one that dropped the last row." \u274C e.g. "\u{1F916} Generated with Claude Code."\n Skip this step entirely for bundle tasks \u2014 per-task bodies are not combined for bundle PRs.\n\nAfter writing all done.md files:\n- Read tasks/<id>/notes.md for each task. For each insight, ask: "would this have changed how a *different* task was approached?" If yes, **append** one new entry for *this* task to docs/lessons-learned.md. If no, the detail stays in notes.md only. Append-only: never edit, prune, promote, reorganize, or delete existing entries \u2014 not this task\'s earlier entries, and never another task\'s. Promoting entries into permanent docs (patterns.md / decisions.md) and pruning the buffer is a **human-initiated, human-approved** action \u2014 never perform it during QA, and no entry count ever triggers it. (See docs/lessons-learned.md \u2192 "How to use this doc".)\n- Add a `## Quality Log` section to done.md with the five judgment cells the pipeline cannot derive from status.json. Do **not** edit docs/task-quality-log.md directly \u2014 the qa \u2192 done transition writes or upserts that row automatically from status.json plus this section.\n\n ```markdown\n ## Quality Log\n - Spec verdict: <the FIRST spec_review verdict this task ever received \u2014 approved / approved_with_nits / changes_requested; leave blank only if truly unknown>\n - Human reroute?: <Yes/No \u2014 did the human reject at human_review and force a re-implement? Do not infer this from any reroute counter; if this is a fresh QA pass with no human_review rejection yet, answer No>\n - Dropped ACs: <count of ACs the implementation missed, caught in code review>\n - Validation gaps: <count of validation checks that should have run but did not>\n - Notes: <one-line summary of anything notable \u2014 single line, no embedded line breaks>\n ```\n\n On a re-upsert after a reroute, only fill in cells that changed or that you are correcting \u2014 an omitted cell keeps its previously recorded value. In particular, do not overwrite an already-recorded `Spec verdict` with the current status.json verdict; that column means the first spec_review verdict, and status.json only retains the latest.\n\n**Handoff Validation pre-merge checklist** (include in `done.md` Human Verification section if any item cannot be confirmed):\n- [ ] Version correct (per project policy; skip if unversioned)\n- [ ] Changelog updated if needed (per project policy; skip if unversioned)\n- [ ] PR body current\n- [ ] Final CI/CD checks green\n- [ ] Final diff matches spec intent\n\n**Output Format for Human** \u2014 `done.md` must contain:\n1. One-paragraph plain-English summary\n2. Files changed\n3. How to test (product-level steps, not code)\n4. Test results table\n5. Decisions made during implementation\n6. Open questions needing human input\n\n**Code is Canonical; Docs Reference Symbols**: Code is the source of truth for anything derivable from code: numbers, thresholds, file locations, function signatures, type shapes, observable behavior. Docs that restate these facts inline rot silently \u2014 reference the symbol or path; do not restate the value.\n\n**Commit Ownership** \u2014 three change categories:\n- Code changes \u2192 task branch, committed by the orchestrator after Codex static validation.\n- Pre-implement scaffold \u2192 base branch, committed by the orchestrator before first implement.\n- Changelog + version bump (if versioned) \u2192 separate commit, human + Claude, after human_review.\n\n- **Docs freshness \u2014 Two-checkpoint**: {{#docsEditable}}scan these docs ({{{docsEditable}}}) for references that {{docsScope}} *contradicts* \u2014 a renamed symbol, a moved file, a behavior this task changed \u2014 and correct those stale references. That is the only edit QA makes to permanent docs.{{/docsEditable}}{{^docsEditable}}no docs are configured for correction in this project, so QA makes no edits to permanent docs under this step.{{/docsEditable}} Do not add new lessons, pitfalls, or decisions here, and do not promote buffer entries \u2014 promotion is the human sweep, not Docs freshness.\n{{#docsFlagged}}\n- **Docs drift (flag only)**: also check {{{docsFlagged}}} for statements {{docsScope}} makes stale. Do **not** edit these docs. Report each stale statement in a `## Docs drift` section of done.md (doc, the stale statement, and what is now true). Omit the section if nothing is stale.\n{{/docsFlagged}}\n- **Buffer signal** (not an action): after appending, if docs/lessons-learned.md now
|
|
5000
|
+
var qa_default = 'You are writing QA summaries for {{taskScope}} for {{projectName}}.\n\n{{{startup}}}\n\nTasks:\n{{{taskLines}}}\n\nFor each task:\n1. **Use the Write tool** to create tasks/<id>/done.md \u2014 plain-English summary for the human. Include: what changed, files changed, how to test, test results, human verification required, decisions made, open questions.\n \u26A0\uFE0F CRITICAL: Use the `Write` tool \u2014 do NOT simply output the done.md content as text in your response. Content in your chat reply does not get saved to disk. The pipeline validates that done.md contains real content (not the template) before advancing. Write the file.\n2. Read the latest `## Validation Outcomes` table in `tasks/<id>/handoff.md`, including any later iteration `### Re-run validation` tables. If any check\'s latest result is `human_pending`, include a **Human Verification Required** section in done.md that lists each pending check and its Notes. If none remain, write `None.` in that section. Do not hide `human_pending` checks inside the generic Test Results table.\n - If the human chooses to waive or defer a pending check later, the waiver line in done.md must begin with `Acknowledged:`. The `human_review` gate only treats that explicit prefix as a waiver.\n - Preserve `deferred_by_spec` rows in Test Results with the spec citation from Notes; do not translate them to `Pass`.\n3. Include a **Proposed Changelog** section in done.md \u2014 the changelog **entry text only**. Do **NOT** include a version number, a "Proposed version" line, or a bump tier (Patch/Minor/Major): the version is decided at the release step (`/canon-changelog` + human), never by QA.\n - **Canon release rules (non-negotiable)**: (1) Agents do not bump versions or land changelog edits without explicit scope authorization. (2) The QA step proposes a draft changelog entry text only \u2014 not the version number. (3) Changelog + version bump are committed separately from code changes (when a project versions its releases). (4) No major versioning surprises: if a task introduces a breaking change the spec didn\'t flag, raise it before shipping.\n - Read `docs/decisions.md` \xA7"Versioning and release policy" for this project\'s changelog **scope** (which changes warrant an entry). The bump tier / SemVer interpretation is the release step\'s call, not QA\'s \u2014 do not propose one.\n - If CHANGELOG.md exists, read the top of it (the most recent version section) to calibrate on scope and voice.\n - Apply the "would a user notice" test to every candidate bullet (or the project\'s equivalent scope test): if a candidate falls outside the project\'s defined changelog scope, omit it. If a task is entirely out of scope, say so explicitly ("no user-facing change \u2014 omit from changelog") rather than inventing a bullet.\n - Implementation mechanics belong in the "What Changed" section above \u2014 not in the proposed changelog.\n The human finalizes the changelog entry.\n4. **For single tasks only \u2014 use the Write tool** to create `tasks/<id>/pr-body.md` \u2014 the outward-facing PR body draft for `--pr`. Write it as if a human wrote it after doing the work.\n {{#prTemplate}}\n The repo has this PR template. Fill every section with specifics from what shipped. Keep the headings; replace every placeholder:\n\n {{{prTemplate}}}\n {{/prTemplate}}\n {{^prTemplate}}\n No PR template found. Use this structure:\n\n ## Summary\n 1\u20133 bullets: what changed and why.\n\n ## Changes\n Key files or areas touched, described for a reviewer.\n\n ## How to Test\n Steps a reviewer can follow to verify the change.\n\n ## Notes for Reviewer\n Any context, caveats, or follow-up items.\n {{/prTemplate}}\n \u26A0\uFE0F Write as the human engineer who did the work \u2014 not as the AI or tool that produced it (Claude, Codex, canon, an LLM). \u2705 e.g. "Fix the pagination off-by-one that dropped the last row." \u274C e.g. "\u{1F916} Generated with Claude Code."\n Skip this step entirely for bundle tasks \u2014 per-task bodies are not combined for bundle PRs.\n\nAfter writing all done.md files:\n- Read tasks/<id>/notes.md for each task. For each insight, ask: "would this have changed how a *different* task was approached?" If yes, **append** one new entry for *this* task to its own lessons fragment, using the entry format in docs/lessons-learned.md. The fragment holds only `###` entries: create it if it doesn\'t exist, and give it no title or other `#`/`##` heading. Do not edit docs/lessons-learned.md; `--ship` folds the fragments into it. If no, the detail stays in notes.md only. Append-only: never edit, prune, promote, reorganize, or delete existing entries \u2014 not this task\'s earlier entries, and never another task\'s. Promoting entries into permanent docs (patterns.md / decisions.md) and pruning the buffer is a **human-initiated, human-approved** action \u2014 never perform it during QA, and no entry count ever triggers it. (See docs/lessons-learned.md \u2192 "How to use this doc".)\n\n{{{lessonsFragments}}}\n\n- Add a `## Quality Log` section to done.md with the five judgment cells the pipeline cannot derive from status.json. Do **not** edit docs/task-quality-log.md directly \u2014 the qa \u2192 done transition writes or upserts that row automatically into each task\'s quality-log.md fragment from status.json plus this section. `--ship` folds it into the shared log.\n\n ```markdown\n ## Quality Log\n - Spec verdict: <the FIRST spec_review verdict this task ever received \u2014 approved / approved_with_nits / changes_requested; leave blank only if truly unknown>\n - Human reroute?: <Yes/No \u2014 did the human reject at human_review and force a re-implement? Do not infer this from any reroute counter; if this is a fresh QA pass with no human_review rejection yet, answer No>\n - Dropped ACs: <count of ACs the implementation missed, caught in code review>\n - Validation gaps: <count of validation checks that should have run but did not>\n - Notes: <one-line summary of anything notable \u2014 single line, no embedded line breaks>\n ```\n\n On a re-upsert after a reroute, only fill in cells that changed or that you are correcting \u2014 an omitted cell keeps its previously recorded value. In particular, do not overwrite an already-recorded `Spec verdict` with the current status.json verdict; that column means the first spec_review verdict, and status.json only retains the latest.\n\n**Handoff Validation pre-merge checklist** (include in `done.md` Human Verification section if any item cannot be confirmed):\n- [ ] Version correct (per project policy; skip if unversioned)\n- [ ] Changelog updated if needed (per project policy; skip if unversioned)\n- [ ] PR body current\n- [ ] Final CI/CD checks green\n- [ ] Final diff matches spec intent\n\n**Output Format for Human** \u2014 `done.md` must contain:\n1. One-paragraph plain-English summary\n2. Files changed\n3. How to test (product-level steps, not code)\n4. Test results table\n5. Decisions made during implementation\n6. Open questions needing human input\n\n**Code is Canonical; Docs Reference Symbols**: Code is the source of truth for anything derivable from code: numbers, thresholds, file locations, function signatures, type shapes, observable behavior. Docs that restate these facts inline rot silently \u2014 reference the symbol or path; do not restate the value.\n\n**Commit Ownership** \u2014 three change categories:\n- Code changes \u2192 task branch, committed by the orchestrator after Codex static validation.\n- Pre-implement scaffold \u2192 base branch, committed by the orchestrator before first implement.\n- Changelog + version bump (if versioned) \u2192 separate commit, human + Claude, after human_review.\n\n- **Docs freshness \u2014 Two-checkpoint**: {{#docsEditable}}scan these docs ({{{docsEditable}}}) for references that {{docsScope}} *contradicts* \u2014 a renamed symbol, a moved file, a behavior this task changed \u2014 and correct those stale references. That is the only edit QA makes to permanent docs.{{/docsEditable}}{{^docsEditable}}no docs are configured for correction in this project, so QA makes no edits to permanent docs under this step.{{/docsEditable}} Do not add new lessons, pitfalls, or decisions here, and do not promote buffer entries \u2014 promotion is the human sweep, not Docs freshness.\n{{#docsFlagged}}\n- **Docs drift (flag only)**: also check {{{docsFlagged}}} for statements {{docsScope}} makes stale. Do **not** edit these docs. Report each stale statement in a `## Docs drift` section of done.md (doc, the stale statement, and what is now true). Omit the section if nothing is stale.\n{{/docsFlagged}}\n- **Buffer signal** (not an action): after appending, if docs/lessons-learned.md plus this task\'s lessons fragment now hold more than ~15 entries, add one line to this task\'s done.md \u2014 `Maintenance: lessons-learned.md plus this task\u2019s fragment have N entries; a human lessons sweep is due (see docs/lessons-learned.md \u2192 "How to use this doc").` Do not perform the sweep yourself.\n\nWhen done, run (use the Bash tool \u2014 do not just output the command as text):\n{{{phaseCommands}}}\n';
|
|
4987
5001
|
|
|
4988
5002
|
// src/orchestrator/prompts/templates/spec.md
|
|
4989
5003
|
var spec_default = '{{{header}}}\n\n{{{startup}}}\n\n{{{instructions}}}\n{{{bundleNote}}}\n{{#doneNote}}\nNote: {{{doneNote}}}{{/doneNote}}\n\n{{#docsMapBlock}}\n{{{docsMapBlock}}}\n\n{{/docsMapBlock}}\n**Spec-writing rules of thumb** \u2014 apply when writing each spec:\n\n- **Name effects to DELETE** \u2014 frame supersession as replacement, not add-plus-remove. State: "replace `oldFn` with `newFn`; `oldFn` must not exist after" \u2014 not separate "Add" and "Remove" bullets.\n- **Prefer positive or structural assertions** over prose negations for load-bearing constraints. Back a "must not" with a grep AC or positive reframe; bare prose negation is fragile.\n- **Symbols named in ACs must exist** \u2014 grep for every function or symbol an AC names; verify its return shape matches the spec\'s assumed data contract before marking spec done.\n- **Enumerate every caller and classify its execution context before asserting how a mechanism works** \u2014 "counter X only increments on event Y" or "this writer can only be invoked from surface Z" is a control-flow claim, and control-flow claims need every caller traced. A helper named for one trigger routinely serves several, and whether a call site runs inside an agent\'s own session or in the orchestrator\'s process is invisible from the function body. Grep every caller of the function or counter \u2014 including skills and docs, not just source \u2014 and classify each site as in-agent-session or orchestrator-context before the spec asserts anything about it.\n- **Behavioral contracts, not mechanics** \u2014 ACs describe observable behavior; defer implementation mechanics (signatures, constant names, precise algorithms) to plan/implement. If review rounds start re-litigating literal regexes, exact file layouts, or verbatim command strings, the spec has dropped to code altitude and each round\'s "underspecified" finding just adds more reviewable surface: move the mechanics into a clearly-labeled non-binding *Implementation Notes* subsection owned by plan/implement, rather than wording the same mechanism more precisely.\n- **Bug and flake-fix specs need a confirmed mechanism and red-first test** \u2014 For a bug or flake fix, the spec author must state, in *Problem*, both the confirmed mechanism and how it was confirmed \u2014 not merely a plausible cause. Evidence must match the mechanism class: a deterministic mechanism (fixed inputs hit the same wrong branch every run) may cite a trace with the verified trigger values; a runtime-dependent mechanism (race, timing, environment/config interaction) needs executed confirmation \u2014 a throwaway prototype-fix spike that makes the symptom vanish, or a deterministic forced repro. The author must satisfy that checkpoint before the spec is marked done; on fast-tier (XS, non-delicate) tasks the `spec_review` checkpoint is skipped, so no reviewer will catch an unverified mechanism. The *Acceptance Criteria* must include a red-first regression-test AC: a test that fails on the pre-fix code for the stated reason and passes after the fix. If the mechanism is environment-bound and a faithful repro is impractical, *Problem* must say so and name a deterministic alternative (integration fixture or documented manual repro) instead of skipping verification silently.\n- **At \u22653 spec_review iterations, read the rounds\' content, not the count** \u2014 label each round *edge-fine-tune* (it narrows an already-identified case \u2014 converging, safe to finish once the remaining findings are addressed) or *scope-expansion* (it names a **new** structural case the spec hadn\'t covered \u2014 a real design gap). Round count alone is not the signal; a 6-round run of successive fine-tunes is convergence, not churn. On genuine scope-expansion, don\'t refine again: check whether the last two rejections share one structural root, and whether an existing layer already owns the concern. Dropping the whole mechanism class \u2014 or deleting the mechanism in favor of the layer that already owns it \u2014 is usually cheaper than another round of hardening.\n- **Refactor specs need structural caps** \u2014 provide hard size caps, explicit deletion expectations per symbol, and an allow-list grep AC for any symbol that must disappear.\n- **Refactor specs need a correctness audit** \u2014 For each behavior a refactor declares preserved, record in Problem whether it is correct today, and state one outcome: correct as-is, fixed deliberately (with its own AC), split into a separate task, or kept as a named quirk. A behavior confirmed correct needs only a one-line record. N/A for features and bug fixes.\n- **UI spatial / gesture tasks** \u2014 flag "visual positioning \u2014 expect human iteration" or "runtime debugging required" in *Known Risks*.\n- **Sensitive-surface escalation** \u2014 flag these categories as `delicate: true` in `status.json` and call them out in *Known Risks*: auth, billing / payments, privacy / data handling, destructive operations, schema / data-model migrations, analytics-event changes. The human spec gate is where such tasks stop for review.\n\n{{{selfCheck}}}\n\nWhen done, run (one per task):\n{{{phaseCommands}}}\n';
|
|
@@ -5418,6 +5432,10 @@ function promptQa(state, prTemplate, docs = { editable: DEFAULT_PROTECTED_DOCS,
|
|
|
5418
5432
|
(t) => `- \`${t.taskId}\`: "${t.title}" \u2192 tasks/${t.taskId}/`
|
|
5419
5433
|
).join("\n");
|
|
5420
5434
|
return render3("qa.md", {
|
|
5435
|
+
lessonsFragments: tasks.length === 1 ? ` Append the lesson to \`tasks/${tasks[0].taskId}/lessons.md\`.` : [
|
|
5436
|
+
" Append each member\u2019s lesson to that member\u2019s fragment:",
|
|
5437
|
+
...tasks.map((t) => ` - Task \`${t.taskId}\`: \`tasks/${t.taskId}/lessons.md\``)
|
|
5438
|
+
].join("\n"),
|
|
5421
5439
|
docsEditable: docs.editable.map((doc) => `\`${doc}\``).join(", "),
|
|
5422
5440
|
docsFlagged: docs.flagged.map((doc) => `\`${doc}\``).join(", "),
|
|
5423
5441
|
projectName: config.projectName,
|
|
@@ -5445,7 +5463,7 @@ function isInstalledSourcePath(sourcePath) {
|
|
|
5445
5463
|
return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
|
|
5446
5464
|
}
|
|
5447
5465
|
function resolveCanonVersion(explicit) {
|
|
5448
|
-
return explicit ?? "3.
|
|
5466
|
+
return explicit ?? "3.10.1" ?? "dev";
|
|
5449
5467
|
}
|
|
5450
5468
|
function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
|
|
5451
5469
|
const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
|
|
@@ -5581,9 +5599,6 @@ var JUDGMENT_LABELS = {
|
|
|
5581
5599
|
"validation gaps": "Validation gaps",
|
|
5582
5600
|
"notes": "Notes"
|
|
5583
5601
|
};
|
|
5584
|
-
function getQualityLogFile(activeCwd) {
|
|
5585
|
-
return process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path14.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path14.join(activeCwd, "docs/task-quality-log.md");
|
|
5586
|
-
}
|
|
5587
5602
|
function normalizeCellValue(value) {
|
|
5588
5603
|
return value.replace(/\r\n|\n/g, " ");
|
|
5589
5604
|
}
|
|
@@ -5611,12 +5626,12 @@ function splitTableRowCells(line) {
|
|
|
5611
5626
|
continue;
|
|
5612
5627
|
}
|
|
5613
5628
|
if (backslashes > 0) {
|
|
5614
|
-
cell += "\\".repeat(backslashes);
|
|
5629
|
+
cell += "\\".repeat(Math.ceil(backslashes / 2));
|
|
5615
5630
|
backslashes = 0;
|
|
5616
5631
|
}
|
|
5617
5632
|
cell += char;
|
|
5618
5633
|
}
|
|
5619
|
-
if (backslashes > 0) cell += "\\".repeat(backslashes);
|
|
5634
|
+
if (backslashes > 0) cell += "\\".repeat(Math.ceil(backslashes / 2));
|
|
5620
5635
|
cells.push(cell);
|
|
5621
5636
|
const innerCells = cells.slice(
|
|
5622
5637
|
(cells[0] ?? "").trim() === "" ? 1 : 0,
|
|
@@ -5744,6 +5759,26 @@ function writeFileAtomic(filePath, content) {
|
|
|
5744
5759
|
}
|
|
5745
5760
|
}
|
|
5746
5761
|
}
|
|
5762
|
+
function replaceTaskRow(lines, located, taskId, buildRow) {
|
|
5763
|
+
const logRows = parseLogRows(
|
|
5764
|
+
lines,
|
|
5765
|
+
located.headerCells,
|
|
5766
|
+
located.dataStart,
|
|
5767
|
+
located.dataEnd
|
|
5768
|
+
);
|
|
5769
|
+
const strayRows = parseStrayRows(lines, located.headerCells);
|
|
5770
|
+
const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === taskId);
|
|
5771
|
+
const finalRow = buildRow(taskRows);
|
|
5772
|
+
const rendered = renderRowLine(located.headerCells, finalRow);
|
|
5773
|
+
const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
|
|
5774
|
+
const updatedLines = [];
|
|
5775
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
5776
|
+
if (index === located.dataEnd) updatedLines.push(rendered);
|
|
5777
|
+
if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
|
|
5778
|
+
}
|
|
5779
|
+
if (located.dataEnd >= lines.length) updatedLines.push(rendered);
|
|
5780
|
+
return updatedLines;
|
|
5781
|
+
}
|
|
5747
5782
|
function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
|
|
5748
5783
|
try {
|
|
5749
5784
|
let content;
|
|
@@ -5766,24 +5801,7 @@ function upsertQualityLogRow(logFilePath, derived, qaSupplied) {
|
|
|
5766
5801
|
);
|
|
5767
5802
|
return;
|
|
5768
5803
|
}
|
|
5769
|
-
const
|
|
5770
|
-
lines,
|
|
5771
|
-
located.headerCells,
|
|
5772
|
-
located.dataStart,
|
|
5773
|
-
located.dataEnd
|
|
5774
|
-
);
|
|
5775
|
-
const strayRows = parseStrayRows(lines, located.headerCells);
|
|
5776
|
-
const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === derived.taskId);
|
|
5777
|
-
const reconciled = reconcileHistory(taskRows, located.headerCells);
|
|
5778
|
-
const finalRow = buildFinalRow(located.headerCells, derived, reconciled, qaSupplied);
|
|
5779
|
-
const rendered = renderRowLine(located.headerCells, finalRow);
|
|
5780
|
-
const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
|
|
5781
|
-
const updatedLines = [];
|
|
5782
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
5783
|
-
if (index === located.dataEnd) updatedLines.push(rendered);
|
|
5784
|
-
if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
|
|
5785
|
-
}
|
|
5786
|
-
if (located.dataEnd >= lines.length) updatedLines.push(rendered);
|
|
5804
|
+
const updatedLines = replaceTaskRow(lines, located, derived.taskId, (taskRows) => buildFinalRow(located.headerCells, derived, reconcileHistory(taskRows, located.headerCells), qaSupplied));
|
|
5787
5805
|
writeFileAtomic(logFilePath, updatedLines.join("\n"));
|
|
5788
5806
|
} catch (error) {
|
|
5789
5807
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -5803,15 +5821,17 @@ function parseQualityLogJudgmentBlock(doneMdContent) {
|
|
|
5803
5821
|
}
|
|
5804
5822
|
return result;
|
|
5805
5823
|
}
|
|
5806
|
-
function writeQualityLogForTask(taskId,
|
|
5824
|
+
function writeQualityLogForTask(taskId, _activeCwd, donePath, status) {
|
|
5807
5825
|
try {
|
|
5808
5826
|
let doneContent = "";
|
|
5809
5827
|
try {
|
|
5810
5828
|
doneContent = fs15.readFileSync(donePath, "utf8");
|
|
5811
5829
|
} catch {
|
|
5812
5830
|
}
|
|
5831
|
+
const target = process.env.CANON_QUALITY_LOG_FILE_OVERRIDE ? path14.resolve(process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) : path14.join(path14.dirname(donePath), "quality-log.md");
|
|
5832
|
+
if (!process.env.CANON_QUALITY_LOG_FILE_OVERRIDE) fs15.mkdirSync(path14.dirname(target), { recursive: true });
|
|
5813
5833
|
upsertQualityLogRow(
|
|
5814
|
-
|
|
5834
|
+
target,
|
|
5815
5835
|
{
|
|
5816
5836
|
taskId,
|
|
5817
5837
|
taskSize: status.task_size,
|
|
@@ -5826,6 +5846,25 @@ function writeQualityLogForTask(taskId, activeCwd, donePath, status) {
|
|
|
5826
5846
|
warn(`quality-log: failed to write row for '${taskId}': ${message}`);
|
|
5827
5847
|
}
|
|
5828
5848
|
}
|
|
5849
|
+
function foldQualityLogFragment(shared, fragment, taskId) {
|
|
5850
|
+
const fragmentLines = fragment.split("\n");
|
|
5851
|
+
const fragmentTable = locateLogTable(fragmentLines);
|
|
5852
|
+
if (!fragmentTable) throw new Error(`quality-log fragment for '${taskId}' has no well-formed '## Log' table`);
|
|
5853
|
+
const fragmentRows = parseLogRows(fragmentLines, fragmentTable.headerCells, fragmentTable.dataStart, fragmentTable.dataEnd).filter((row) => row.cells.Task === taskId);
|
|
5854
|
+
if (!fragmentRows.length) return null;
|
|
5855
|
+
const lines = (shared ?? STANDARD_QUALITY_LOG_SKELETON).split("\n");
|
|
5856
|
+
const located = locateLogTable(lines);
|
|
5857
|
+
if (!located) throw new Error("docs/task-quality-log.md has no well-formed '## Log' table with all required columns");
|
|
5858
|
+
return replaceTaskRow(lines, located, taskId, (taskRows) => {
|
|
5859
|
+
const history = [...taskRows, ...fragmentRows.map((row, index) => ({ ...row, lineIndex: lines.length + index }))];
|
|
5860
|
+
const reconciled = reconcileHistory(history, located.headerCells);
|
|
5861
|
+
const latest = fragmentRows[fragmentRows.length - 1].cells;
|
|
5862
|
+
return Object.fromEntries(located.headerCells.map((header) => [
|
|
5863
|
+
header,
|
|
5864
|
+
header === "Task" ? taskId : DERIVED_HEADERS.has(header) ? latest[header] ?? "" : reconciled[header] ?? ""
|
|
5865
|
+
]));
|
|
5866
|
+
}).join("\n");
|
|
5867
|
+
}
|
|
5829
5868
|
|
|
5830
5869
|
// src/task/index.ts
|
|
5831
5870
|
var VALID_PHASES = new Set(PHASE_ORDER);
|
|
@@ -6828,6 +6867,138 @@ function removeCanonPid(taskDir) {
|
|
|
6828
6867
|
}
|
|
6829
6868
|
}
|
|
6830
6869
|
|
|
6870
|
+
// src/orchestrator/telemetry-fold.ts
|
|
6871
|
+
import fs22 from "fs";
|
|
6872
|
+
import path21 from "path";
|
|
6873
|
+
import { randomUUID } from "crypto";
|
|
6874
|
+
var LESSONS_SKELETON = "# Lessons Learned\n\n> Distilled cross-task insights. Task lessons fragments are folded here by `canon run --ship`.\n";
|
|
6875
|
+
var METRICS_HEADER = /^\|\s*Timestamp\s*\|/;
|
|
6876
|
+
function foldMetricsFragment(shared, fragment) {
|
|
6877
|
+
const rows = fragment.split("\n").filter((line) => line.trimStart().startsWith("|") && !METRICS_HEADER.test(line.trimStart()) && !/^\|(\s*:?-+:?\s*\|)+\s*$/.test(line.trim()));
|
|
6878
|
+
if (!rows.length) return null;
|
|
6879
|
+
const lines = (shared ?? METRICS_SKELETON).split("\n");
|
|
6880
|
+
const header = lines.findIndex((line) => METRICS_HEADER.test(line.trimStart()));
|
|
6881
|
+
if (header === -1) throw new Error("docs/pipeline-invocations.md has no '| Timestamp |' table header");
|
|
6882
|
+
let end = header + 1;
|
|
6883
|
+
while (end < lines.length && lines[end].trimStart().startsWith("|")) end += 1;
|
|
6884
|
+
lines.splice(end, 0, ...rows);
|
|
6885
|
+
return lines.join("\n").replace(/\n?$/, "\n");
|
|
6886
|
+
}
|
|
6887
|
+
function stripFragmentTitles(fragment) {
|
|
6888
|
+
const lines = fragment.split("\n");
|
|
6889
|
+
const firstEntry = lines.findIndex((line) => /^ {0,3}###\s/.test(line));
|
|
6890
|
+
const head = firstEntry === -1 ? lines : lines.slice(0, firstEntry);
|
|
6891
|
+
const rest = firstEntry === -1 ? [] : lines.slice(firstEntry);
|
|
6892
|
+
return [...head.filter((line) => !/^ {0,3}#{1,2}\s/.test(line)), ...rest].join("\n");
|
|
6893
|
+
}
|
|
6894
|
+
function foldLessonsFragment(shared, fragment) {
|
|
6895
|
+
const body = stripFragmentTitles(fragment).trim();
|
|
6896
|
+
if (!body) return null;
|
|
6897
|
+
const base = (shared ?? LESSONS_SKELETON).trimEnd();
|
|
6898
|
+
const separator = base.endsWith("---") || body.startsWith("---") ? "\n\n" : "\n\n---\n\n";
|
|
6899
|
+
return `${base}${separator}${body}
|
|
6900
|
+
`;
|
|
6901
|
+
}
|
|
6902
|
+
function sharedTelemetryDocsTouched(paths) {
|
|
6903
|
+
return PIPELINE_TELEMETRY_FILES.filter((file) => paths.includes(file));
|
|
6904
|
+
}
|
|
6905
|
+
function readOptional(file) {
|
|
6906
|
+
try {
|
|
6907
|
+
return fs22.readFileSync(file, "utf8");
|
|
6908
|
+
} catch (error) {
|
|
6909
|
+
if (error.code === "ENOENT") return null;
|
|
6910
|
+
throw error;
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
6913
|
+
function planTelemetryFold(repoRoot, taskIds, taskDirFor3) {
|
|
6914
|
+
const kinds = [
|
|
6915
|
+
{ name: "metrics.md", target: getSharedMetricsFile(repoRoot), fold: foldMetricsFragment },
|
|
6916
|
+
{ name: "quality-log.md", target: path21.join(repoRoot, "docs/task-quality-log.md"), fold: foldQualityLogFragment },
|
|
6917
|
+
{ name: "lessons.md", target: path21.join(repoRoot, "docs/lessons-learned.md"), fold: foldLessonsFragment }
|
|
6918
|
+
];
|
|
6919
|
+
const before = new Map(kinds.map((kind) => [kind.target, readOptional(kind.target)]));
|
|
6920
|
+
const contents = new Map(before);
|
|
6921
|
+
const fragments = [];
|
|
6922
|
+
for (const taskId of taskIds) {
|
|
6923
|
+
for (const kind of kinds) {
|
|
6924
|
+
const fragmentPath = path21.join(taskDirFor3(taskId), kind.name);
|
|
6925
|
+
const content = readOptional(fragmentPath);
|
|
6926
|
+
if (content === null) continue;
|
|
6927
|
+
fragments.push({ path: fragmentPath, content });
|
|
6928
|
+
try {
|
|
6929
|
+
const folded = kind.fold(contents.get(kind.target) ?? null, content, taskId);
|
|
6930
|
+
if (folded !== null) contents.set(kind.target, folded);
|
|
6931
|
+
} catch (error) {
|
|
6932
|
+
throw new Error(`${fragmentPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6933
|
+
}
|
|
6934
|
+
}
|
|
6935
|
+
}
|
|
6936
|
+
return {
|
|
6937
|
+
writes: [...contents].flatMap(([target, after]) => after !== null && after !== before.get(target) ? [{ target, before: before.get(target) ?? null, after }] : []),
|
|
6938
|
+
fragments
|
|
6939
|
+
};
|
|
6940
|
+
}
|
|
6941
|
+
function applyTelemetryFold(plan) {
|
|
6942
|
+
const temps = [];
|
|
6943
|
+
const written = [];
|
|
6944
|
+
const deleted = [];
|
|
6945
|
+
try {
|
|
6946
|
+
for (const write of plan.writes) {
|
|
6947
|
+
fs22.mkdirSync(path21.dirname(write.target), { recursive: true });
|
|
6948
|
+
const temp = `${write.target}.${randomUUID()}.canon-fold.tmp`;
|
|
6949
|
+
const fd = fs22.openSync(temp, "wx");
|
|
6950
|
+
temps.push(temp);
|
|
6951
|
+
try {
|
|
6952
|
+
fs22.writeFileSync(fd, write.after, "utf8");
|
|
6953
|
+
} finally {
|
|
6954
|
+
fs22.closeSync(fd);
|
|
6955
|
+
}
|
|
6956
|
+
}
|
|
6957
|
+
for (let index = 0; index < plan.writes.length; index += 1) {
|
|
6958
|
+
fs22.renameSync(temps[index], plan.writes[index].target);
|
|
6959
|
+
written.push(plan.writes[index]);
|
|
6960
|
+
}
|
|
6961
|
+
for (const fragment of plan.fragments) {
|
|
6962
|
+
fs22.unlinkSync(fragment.path);
|
|
6963
|
+
deleted.push(fragment);
|
|
6964
|
+
}
|
|
6965
|
+
} catch (error) {
|
|
6966
|
+
const rollbackErrors = [];
|
|
6967
|
+
const restore = (action) => {
|
|
6968
|
+
try {
|
|
6969
|
+
action();
|
|
6970
|
+
} catch (rollbackError) {
|
|
6971
|
+
rollbackErrors.push(String(rollbackError));
|
|
6972
|
+
}
|
|
6973
|
+
};
|
|
6974
|
+
for (const write of written.reverse()) {
|
|
6975
|
+
restore(() => {
|
|
6976
|
+
if (write.before === null) fs22.unlinkSync(write.target);
|
|
6977
|
+
else fs22.writeFileSync(write.target, write.before, "utf8");
|
|
6978
|
+
});
|
|
6979
|
+
}
|
|
6980
|
+
for (const fragment of deleted) restore(() => fs22.writeFileSync(fragment.path, fragment.content, "utf8"));
|
|
6981
|
+
if (rollbackErrors.length) throw new Error(`${String(error)}; rollback failed: ${rollbackErrors.join("; ")}`);
|
|
6982
|
+
throw error;
|
|
6983
|
+
} finally {
|
|
6984
|
+
for (const temp of temps) {
|
|
6985
|
+
try {
|
|
6986
|
+
fs22.unlinkSync(temp);
|
|
6987
|
+
} catch {
|
|
6988
|
+
}
|
|
6989
|
+
}
|
|
6990
|
+
}
|
|
6991
|
+
}
|
|
6992
|
+
function foldTelemetryFragments(repoRoot, taskIds, taskDirFor3) {
|
|
6993
|
+
const plan = planTelemetryFold(repoRoot, taskIds, taskDirFor3);
|
|
6994
|
+
applyTelemetryFold(plan);
|
|
6995
|
+
return plan.writes.map((write) => path21.relative(repoRoot, write.target));
|
|
6996
|
+
}
|
|
6997
|
+
function formatPostFoldFailureHint(foldedDocs, state, baseBranch) {
|
|
6998
|
+
const location = state === "uncommitted" ? "as uncommitted (possibly staged) edits in the working tree. Do not reset or check out those docs." : `in the local chore: archive commit on ${baseBranch}, which was not pushed. Do not reset the local ${baseBranch} branch.`;
|
|
6999
|
+
return `Folded telemetry rows for ${foldedDocs.join(", ")} remain ${location} Task telemetry fragments were already deleted, so those rows cannot be regenerated from them. Follow "Failure after the telemetry fold" in docs/pipeline-orchestrator.md for manual recovery.`;
|
|
7000
|
+
}
|
|
7001
|
+
|
|
6831
7002
|
// src/orchestrator/main.ts
|
|
6832
7003
|
var REPO_ROOT2 = REPO_ROOT;
|
|
6833
7004
|
var TASKS_DIR2 = TASKS_DIR;
|
|
@@ -7089,7 +7260,7 @@ function autoCommitCode(taskIds, cwd = REPO_ROOT2) {
|
|
|
7089
7260
|
for (const f of allHandoffFiles) {
|
|
7090
7261
|
if (dirtyFiles.has(f)) continue;
|
|
7091
7262
|
if (gitIgnoredHandoffFiles.has(f)) continue;
|
|
7092
|
-
const exists =
|
|
7263
|
+
const exists = fs23.existsSync(path22.join(cwd, f));
|
|
7093
7264
|
if (!exists) {
|
|
7094
7265
|
const committed = gitSafeAt(cwd, "log", "--format=%H", "--max-count=1", `${baseRefForLog}..HEAD`, "--", f);
|
|
7095
7266
|
if (committed.ok && committed.stdout.trim()) {
|
|
@@ -7238,30 +7409,30 @@ function exemptNodeModulesPath(entry, cwd, resolvedWorkspaceDirs) {
|
|
|
7238
7409
|
if (entryPath === null) return null;
|
|
7239
7410
|
if (matchesPorcelainPath(entryPath, "node_modules")) {
|
|
7240
7411
|
return probeNodeModulesEntry(
|
|
7241
|
-
|
|
7242
|
-
|
|
7412
|
+
path22.join(cwd, entryPath),
|
|
7413
|
+
path22.join(REPO_ROOT2, entryPath)
|
|
7243
7414
|
).verdict === "verified-symlink" ? "node_modules" : null;
|
|
7244
7415
|
}
|
|
7245
7416
|
if (!isNodeModulesEntryPath(entryPath)) return null;
|
|
7246
7417
|
let cwdReal;
|
|
7247
7418
|
let repoRootReal;
|
|
7248
7419
|
try {
|
|
7249
|
-
cwdReal =
|
|
7250
|
-
repoRootReal =
|
|
7420
|
+
cwdReal = fs23.realpathSync(cwd);
|
|
7421
|
+
repoRootReal = fs23.realpathSync(REPO_ROOT2);
|
|
7251
7422
|
} catch {
|
|
7252
7423
|
return null;
|
|
7253
7424
|
}
|
|
7254
7425
|
if (cwdReal === repoRootReal) return null;
|
|
7255
7426
|
const workspaceDirs = resolvedWorkspaceDirs ?? resolveWorkspaceDirs(REPO_ROOT2);
|
|
7256
7427
|
for (const workspace of workspaceDirs) {
|
|
7257
|
-
const resolvedDestination = resolveContainedPath(
|
|
7428
|
+
const resolvedDestination = resolveContainedPath(path22.join(cwd, workspace), cwd);
|
|
7258
7429
|
if (resolvedDestination === null) continue;
|
|
7259
|
-
const relativeDestination =
|
|
7430
|
+
const relativeDestination = path22.relative(cwdReal, resolvedDestination).split(path22.sep).join("/");
|
|
7260
7431
|
const expectedEntryPath = `${relativeDestination}/node_modules`;
|
|
7261
7432
|
if (!matchesPorcelainPath(entryPath, expectedEntryPath)) continue;
|
|
7262
7433
|
return probeNodeModulesEntry(
|
|
7263
|
-
|
|
7264
|
-
|
|
7434
|
+
path22.join(resolvedDestination, "node_modules"),
|
|
7435
|
+
path22.join(REPO_ROOT2, workspace, "node_modules")
|
|
7265
7436
|
).verdict === "verified-symlink" ? expectedEntryPath : null;
|
|
7266
7437
|
}
|
|
7267
7438
|
return null;
|
|
@@ -7303,15 +7474,15 @@ function buildHumanReviewStagePaths(taskIds, affectedManagedDocs, dirtyEntries,
|
|
|
7303
7474
|
}
|
|
7304
7475
|
function findPullRequestTemplate(repoRoot) {
|
|
7305
7476
|
const candidates = [
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7477
|
+
path22.join(repoRoot, ".github", "pull_request_template.md"),
|
|
7478
|
+
path22.join(repoRoot, ".github", "PULL_REQUEST_TEMPLATE.md"),
|
|
7479
|
+
path22.join(repoRoot, "docs", "pull_request_template.md"),
|
|
7480
|
+
path22.join(repoRoot, "docs", "PULL_REQUEST_TEMPLATE.md"),
|
|
7481
|
+
path22.join(repoRoot, "pull_request_template.md"),
|
|
7482
|
+
path22.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
|
|
7312
7483
|
];
|
|
7313
7484
|
for (const candidate of candidates) {
|
|
7314
|
-
if (
|
|
7485
|
+
if (fs23.existsSync(candidate)) return candidate;
|
|
7315
7486
|
}
|
|
7316
7487
|
return null;
|
|
7317
7488
|
}
|
|
@@ -7325,13 +7496,13 @@ function resolveQaPrBody(taskIds, activeCwd) {
|
|
|
7325
7496
|
if (taskIds.length !== 1) {
|
|
7326
7497
|
return { kind: "fallback", reason: "bundle: per-task pr-body.md files are not combined in this version" };
|
|
7327
7498
|
}
|
|
7328
|
-
const prBodyPath =
|
|
7499
|
+
const prBodyPath = path22.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
|
|
7329
7500
|
if (!isPrBodyTemplate(prBodyPath)) {
|
|
7330
7501
|
return { kind: "body-file", path: prBodyPath };
|
|
7331
7502
|
}
|
|
7332
7503
|
return {
|
|
7333
7504
|
kind: "fallback",
|
|
7334
|
-
reason:
|
|
7505
|
+
reason: fs23.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
|
|
7335
7506
|
};
|
|
7336
7507
|
}
|
|
7337
7508
|
function commitQaArtifacts(taskIds, cwd) {
|
|
@@ -7452,20 +7623,27 @@ function formatExistingPRMessage(prNum, prUrl) {
|
|
|
7452
7623
|
return `Existing draft PR: #${prNum} (${prUrl})`;
|
|
7453
7624
|
}
|
|
7454
7625
|
function sidecarPathFor(taskId, taskDir = taskDirFor2(taskId)) {
|
|
7455
|
-
return
|
|
7626
|
+
return path22.join(taskDir, ".pr-number");
|
|
7456
7627
|
}
|
|
7457
7628
|
function recordPinnedPRNumber(taskIds, prNum) {
|
|
7458
7629
|
const alreadyPinned = taskIds.every((taskId) => readSidecarPRNumber(taskId) === prNum);
|
|
7459
7630
|
if (alreadyPinned) return;
|
|
7460
7631
|
for (const taskId of taskIds) {
|
|
7461
7632
|
const sidecarPath = sidecarPathFor(taskId);
|
|
7462
|
-
|
|
7463
|
-
|
|
7633
|
+
fs23.mkdirSync(path22.dirname(sidecarPath), { recursive: true });
|
|
7634
|
+
fs23.writeFileSync(sidecarPath, String(prNum), "utf8");
|
|
7464
7635
|
}
|
|
7465
7636
|
}
|
|
7466
|
-
function reportOrCreatePR(taskIds, branchName) {
|
|
7637
|
+
function reportOrCreatePR(taskIds, branchName, cwd) {
|
|
7467
7638
|
if (!ghAvailable) die2("--pr requires the gh CLI, but it is not available.");
|
|
7468
7639
|
const baseBranch = getBaseBranch(taskIds);
|
|
7640
|
+
const affected = getAffectedFiles(baseBranch, cwd);
|
|
7641
|
+
if (affected.ok) {
|
|
7642
|
+
const touched = sharedTelemetryDocsTouched(affected.files);
|
|
7643
|
+
if (touched.length) warn2(`Shared telemetry docs changed: ${touched.join(", ")}. Parallel tasks will conflict there; new runs write task fragments that --ship folds.`);
|
|
7644
|
+
} else {
|
|
7645
|
+
warn2(`Could not check shared telemetry doc drift: ${affected.stderr}`);
|
|
7646
|
+
}
|
|
7469
7647
|
const openPR = findOpenPRNumber(branchName, baseBranch);
|
|
7470
7648
|
let prNum;
|
|
7471
7649
|
if (openPR !== null) {
|
|
@@ -7581,8 +7759,8 @@ function commitHumanReviewFiles(taskIds, cwd, createPR) {
|
|
|
7581
7759
|
` + baseDivergenceResult.commits.map((commit) => ` ${commit.sha.slice(0, 7)} ${commit.subject}`).join("\n")
|
|
7582
7760
|
);
|
|
7583
7761
|
}
|
|
7584
|
-
const docsRefsScript =
|
|
7585
|
-
if (
|
|
7762
|
+
const docsRefsScript = path22.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
|
|
7763
|
+
if (fs23.existsSync(docsRefsScript)) {
|
|
7586
7764
|
const docsRefsResult = spawnSync6("node", [docsRefsScript], {
|
|
7587
7765
|
cwd,
|
|
7588
7766
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -7681,7 +7859,7 @@ This failure cannot be bypassed with --force.`
|
|
|
7681
7859
|
if (!pushResult2.ok) {
|
|
7682
7860
|
die2(`Human review push failed: ${pushResult2.stderr || "unknown error"}`);
|
|
7683
7861
|
}
|
|
7684
|
-
if (createPR) reportOrCreatePR(taskIds, branchName2);
|
|
7862
|
+
if (createPR) reportOrCreatePR(taskIds, branchName2, cwd);
|
|
7685
7863
|
return;
|
|
7686
7864
|
}
|
|
7687
7865
|
}
|
|
@@ -7774,7 +7952,7 @@ If this is a source or test file, it should have been committed during the imple
|
|
|
7774
7952
|
if (!pushResult.ok) {
|
|
7775
7953
|
die2(`Human review push failed: ${pushResult.stderr || "unknown error"}`);
|
|
7776
7954
|
}
|
|
7777
|
-
if (createPR) reportOrCreatePR(taskIds, branchName);
|
|
7955
|
+
if (createPR) reportOrCreatePR(taskIds, branchName, cwd);
|
|
7778
7956
|
}
|
|
7779
7957
|
function printDryRunPlan(state) {
|
|
7780
7958
|
const { tasks } = state;
|
|
@@ -7938,7 +8116,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
|
|
|
7938
8116
|
const sidecarPath = sidecarPathFor(taskId, taskDir);
|
|
7939
8117
|
let raw;
|
|
7940
8118
|
try {
|
|
7941
|
-
raw =
|
|
8119
|
+
raw = fs23.readFileSync(sidecarPath, "utf8").trim();
|
|
7942
8120
|
} catch {
|
|
7943
8121
|
return null;
|
|
7944
8122
|
}
|
|
@@ -7949,7 +8127,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
|
|
|
7949
8127
|
}
|
|
7950
8128
|
function resolveProofPRNumberForPrefetch(taskId, branchName, baseBranch, taskCwd) {
|
|
7951
8129
|
if (!ghAvailable) return null;
|
|
7952
|
-
const pinnedPrNum = readSidecarPRNumber(taskId,
|
|
8130
|
+
const pinnedPrNum = readSidecarPRNumber(taskId, path22.join(taskCwd, "tasks", taskId));
|
|
7953
8131
|
if (pinnedPrNum !== null) return pinnedPrNum;
|
|
7954
8132
|
return findOpenPRNumber(branchName, baseBranch) ?? findMergedPRNumber(branchName, baseBranch);
|
|
7955
8133
|
}
|
|
@@ -7980,7 +8158,7 @@ function establishPRHeadAncestryProof(cwd, prNum, prHead, localTip) {
|
|
|
7980
8158
|
return { proven: true };
|
|
7981
8159
|
}
|
|
7982
8160
|
function establishMergeProof(taskId, branchName, localTip, baseBranch, cwd, prefetchedHeads) {
|
|
7983
|
-
const pinnedPrNum = readSidecarPRNumber(taskId,
|
|
8161
|
+
const pinnedPrNum = readSidecarPRNumber(taskId, path22.join(cwd, "tasks", taskId));
|
|
7984
8162
|
if (ghAvailable && pinnedPrNum !== null) {
|
|
7985
8163
|
if (!isPRMerged(pinnedPrNum)) {
|
|
7986
8164
|
return { proven: false, reason: `Pinned PR #${pinnedPrNum} is not in MERGED state.` };
|
|
@@ -8084,8 +8262,8 @@ function mergeOpenPRsAndPull(taskIds, baseBranch, branchByTaskId) {
|
|
|
8084
8262
|
return anyMerged;
|
|
8085
8263
|
}
|
|
8086
8264
|
function runPostMergeHook() {
|
|
8087
|
-
const hookPath =
|
|
8088
|
-
if (!
|
|
8265
|
+
const hookPath = path22.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
|
|
8266
|
+
if (!fs23.existsSync(hookPath)) return;
|
|
8089
8267
|
info2("Running .canon/hooks/post-merge.sh...");
|
|
8090
8268
|
const result = runCommand2("bash", [hookPath]);
|
|
8091
8269
|
if (!result.ok) {
|
|
@@ -8109,12 +8287,12 @@ function commitArchiveChanges(taskIds, baseBranch) {
|
|
|
8109
8287
|
}
|
|
8110
8288
|
function rewriteArchivedTaskRefs(taskIds) {
|
|
8111
8289
|
const targets = [
|
|
8112
|
-
|
|
8113
|
-
|
|
8290
|
+
path22.join(REPO_ROOT2, "docs", "lessons-learned.md"),
|
|
8291
|
+
path22.join(REPO_ROOT2, "docs", "task-quality-log.md")
|
|
8114
8292
|
];
|
|
8115
8293
|
for (const filePath of targets) {
|
|
8116
|
-
if (!
|
|
8117
|
-
let content =
|
|
8294
|
+
if (!fs23.existsSync(filePath)) continue;
|
|
8295
|
+
let content = fs23.readFileSync(filePath, "utf8");
|
|
8118
8296
|
let changed = false;
|
|
8119
8297
|
for (const taskId of taskIds) {
|
|
8120
8298
|
const stale = `tasks/${taskId}/`;
|
|
@@ -8125,8 +8303,8 @@ function rewriteArchivedTaskRefs(taskIds) {
|
|
|
8125
8303
|
}
|
|
8126
8304
|
}
|
|
8127
8305
|
if (changed) {
|
|
8128
|
-
|
|
8129
|
-
info2(`Updated stale task refs in ${
|
|
8306
|
+
fs23.writeFileSync(filePath, content, "utf8");
|
|
8307
|
+
info2(`Updated stale task refs in ${path22.relative(REPO_ROOT2, filePath)}.`);
|
|
8130
8308
|
}
|
|
8131
8309
|
}
|
|
8132
8310
|
}
|
|
@@ -8161,7 +8339,7 @@ function classifyAndPreserveSharedDocDirt() {
|
|
|
8161
8339
|
if (porcelainCode !== " M") {
|
|
8162
8340
|
return { relPath, docClass, porcelainCode, headContent: null, workingContent: null };
|
|
8163
8341
|
}
|
|
8164
|
-
const workingContent =
|
|
8342
|
+
const workingContent = fs23.readFileSync(path22.join(REPO_ROOT2, relPath), "utf8");
|
|
8165
8343
|
const headResult = gitSafeAtRaw(REPO_ROOT2, "show", `HEAD:${relPath}`);
|
|
8166
8344
|
return {
|
|
8167
8345
|
relPath,
|
|
@@ -8178,11 +8356,11 @@ function classifyAndPreserveSharedDocDirt() {
|
|
|
8178
8356
|
}
|
|
8179
8357
|
const preserve = verdict.preserve;
|
|
8180
8358
|
if (preserve.length === 0) return [];
|
|
8181
|
-
const backupDir =
|
|
8359
|
+
const backupDir = fs23.mkdtempSync(path22.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
|
|
8182
8360
|
const preserved = [];
|
|
8183
8361
|
for (const { relPath, suffix } of preserve) {
|
|
8184
|
-
const backupPath =
|
|
8185
|
-
|
|
8362
|
+
const backupPath = path22.join(backupDir, relPath.replace(/[\\/]/g, "__"));
|
|
8363
|
+
fs23.writeFileSync(backupPath, suffix, "utf8");
|
|
8186
8364
|
info2(`Preserving uncommitted ${relPath} dirt during --ship; backup: ${backupPath}`);
|
|
8187
8365
|
const checkoutResult = gitSafe("checkout", "HEAD", "--", relPath);
|
|
8188
8366
|
if (!checkoutResult.ok) {
|
|
@@ -8196,21 +8374,21 @@ function shipTasks(taskIds) {
|
|
|
8196
8374
|
const resolveShipCwd = (taskId) => {
|
|
8197
8375
|
const tasksDirOverride = process.env.CANON_TASKS_DIR_OVERRIDE;
|
|
8198
8376
|
if (tasksDirOverride) {
|
|
8199
|
-
return
|
|
8377
|
+
return path22.dirname(tasksDirOverride);
|
|
8200
8378
|
}
|
|
8201
8379
|
if (isOrphanedWorktreeState(taskId)) return REPO_ROOT2;
|
|
8202
|
-
return
|
|
8380
|
+
return path22.dirname(path22.dirname(taskDirFor2(taskId)));
|
|
8203
8381
|
};
|
|
8204
8382
|
const taskStatuses = /* @__PURE__ */ new Map();
|
|
8205
8383
|
const readShipStatus = (taskId) => {
|
|
8206
8384
|
const taskCwd = resolveShipCwd(taskId);
|
|
8207
8385
|
const candidates = [
|
|
8208
|
-
|
|
8209
|
-
|
|
8210
|
-
|
|
8386
|
+
path22.join(taskCwd, "tasks", taskId, "status.json"),
|
|
8387
|
+
path22.join(taskCwd, taskId, "status.json"),
|
|
8388
|
+
path22.join(taskDirForRepoRoot2(taskId), "status.json")
|
|
8211
8389
|
];
|
|
8212
8390
|
for (const candidate of candidates) {
|
|
8213
|
-
if (
|
|
8391
|
+
if (fs23.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
|
|
8214
8392
|
}
|
|
8215
8393
|
const snapshot = taskStatuses.get(taskId);
|
|
8216
8394
|
if (snapshot) return snapshot;
|
|
@@ -8242,7 +8420,7 @@ function shipTasks(taskIds) {
|
|
|
8242
8420
|
const currentPhase = getCurrentPhase(readShipStatus(taskId));
|
|
8243
8421
|
if (currentPhase !== "human_review") continue;
|
|
8244
8422
|
const taskCwd = resolveShipCwd(taskId);
|
|
8245
|
-
const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ??
|
|
8423
|
+
const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path22.join(taskCwd, "tasks");
|
|
8246
8424
|
const gateResult = checkPhaseGate(
|
|
8247
8425
|
taskId,
|
|
8248
8426
|
"human_review",
|
|
@@ -8274,10 +8452,18 @@ function shipTasks(taskIds) {
|
|
|
8274
8452
|
return snapshot;
|
|
8275
8453
|
};
|
|
8276
8454
|
let preservedSharedDocDirt = [];
|
|
8455
|
+
const restorePreservedSharedDocDirt = () => {
|
|
8456
|
+
for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
|
|
8457
|
+
fs23.appendFileSync(path22.join(REPO_ROOT2, relPath), suffix, "utf8");
|
|
8458
|
+
fs23.rmSync(backupPath, { force: true });
|
|
8459
|
+
info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
|
|
8460
|
+
}
|
|
8461
|
+
preservedSharedDocDirt = [];
|
|
8462
|
+
};
|
|
8277
8463
|
if (taskIds.some((id) => taskSnapshot(id).worktree)) {
|
|
8278
8464
|
preservedSharedDocDirt = classifyAndPreserveSharedDocDirt();
|
|
8279
8465
|
}
|
|
8280
|
-
const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) =>
|
|
8466
|
+
const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) => path22.join("tasks", taskId, "status.json"));
|
|
8281
8467
|
if (orphanedStatusPaths.length > 0) {
|
|
8282
8468
|
gitSafe("checkout", "HEAD", "--", ...orphanedStatusPaths);
|
|
8283
8469
|
}
|
|
@@ -8350,42 +8536,70 @@ Recovery:
|
|
|
8350
8536
|
- --force does not bypass this gate.`
|
|
8351
8537
|
);
|
|
8352
8538
|
}
|
|
8353
|
-
|
|
8354
|
-
|
|
8539
|
+
let foldedDocs = [];
|
|
8540
|
+
try {
|
|
8541
|
+
foldedDocs = foldTelemetryFragments(REPO_ROOT2, taskIds, taskDirForRepoRoot2);
|
|
8542
|
+
for (const file of foldedDocs) {
|
|
8543
|
+
info2(`Folded task telemetry into ${file}.`);
|
|
8544
|
+
}
|
|
8545
|
+
} catch (error) {
|
|
8546
|
+
restorePreservedSharedDocDirt();
|
|
8547
|
+
die2(`--ship aborted: could not fold task telemetry fragments: ${error instanceof Error ? error.message : String(error)}. Fix the shared docs or fragment write/read error and re-run --ship; no task has been archived.`);
|
|
8548
|
+
}
|
|
8355
8549
|
const localBranchesToDelete = [];
|
|
8356
|
-
|
|
8357
|
-
const
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
|
|
8369
|
-
|
|
8370
|
-
|
|
8371
|
-
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
8375
|
-
|
|
8376
|
-
|
|
8377
|
-
|
|
8378
|
-
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8550
|
+
try {
|
|
8551
|
+
const archiveDir = path22.join(TASKS_DIR2, "_archive");
|
|
8552
|
+
if (!fs23.existsSync(archiveDir)) fs23.mkdirSync(archiveDir, { recursive: true });
|
|
8553
|
+
for (const taskId of taskIds) {
|
|
8554
|
+
const { worktree: hasWorktree } = taskSnapshot(taskId);
|
|
8555
|
+
const status = readShipStatus(taskId);
|
|
8556
|
+
if (hasWorktree) teardownWorktree(taskId);
|
|
8557
|
+
status.updated = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
8558
|
+
const humanReview = status.phases.human_review;
|
|
8559
|
+
if (humanReview) humanReview.status = "done";
|
|
8560
|
+
writeStatusToFile(path22.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
|
|
8561
|
+
const src = taskDirForRepoRoot2(taskId);
|
|
8562
|
+
const dest = path22.join(archiveDir, taskId);
|
|
8563
|
+
fs23.renameSync(src, dest);
|
|
8564
|
+
info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
|
|
8565
|
+
const branchName = taskSnapshot(taskId).branch;
|
|
8566
|
+
if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
|
|
8567
|
+
}
|
|
8568
|
+
rewriteArchivedTaskRefs(taskIds);
|
|
8569
|
+
const stagedPaths = taskIds.flatMap((id) => [
|
|
8570
|
+
path22.join(TASKS_DIR2, id),
|
|
8571
|
+
// deleted source (if not cleaned up)
|
|
8572
|
+
path22.join(TASKS_DIR2, "_archive", id),
|
|
8573
|
+
// new archive destination
|
|
8574
|
+
path22.join(REPO_ROOT2, "docs", "lessons-learned.md"),
|
|
8575
|
+
path22.join(REPO_ROOT2, "docs", "task-quality-log.md"),
|
|
8576
|
+
path22.join(REPO_ROOT2, "docs", "pipeline-invocations.md")
|
|
8577
|
+
]);
|
|
8578
|
+
stageArchiveChanges(stagedPaths);
|
|
8579
|
+
restorePreservedSharedDocDirt();
|
|
8580
|
+
} catch (error) {
|
|
8581
|
+
restorePreservedSharedDocDirt();
|
|
8582
|
+
if (foldedDocs.length === 0) throw error;
|
|
8583
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8584
|
+
die2(`--ship aborted: archive step failed after folding task telemetry: ${message}
|
|
8585
|
+
|
|
8586
|
+
` + formatPostFoldFailureHint(foldedDocs, "uncommitted", baseBranch));
|
|
8587
|
+
}
|
|
8588
|
+
let archiveCommit;
|
|
8589
|
+
try {
|
|
8590
|
+
archiveCommit = commitArchiveChanges(taskIds, baseBranch);
|
|
8591
|
+
} catch (error) {
|
|
8592
|
+
if (foldedDocs.length === 0) throw error;
|
|
8593
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8594
|
+
return die2(`--ship aborted: archive commit was created locally but the push failed: ${message}
|
|
8595
|
+
|
|
8596
|
+
` + formatPostFoldFailureHint(foldedDocs, "unpushed", baseBranch));
|
|
8597
|
+
}
|
|
8387
8598
|
if (archiveCommit.stderr) {
|
|
8388
|
-
|
|
8599
|
+
const hint = foldedDocs.length > 0 ? `
|
|
8600
|
+
|
|
8601
|
+
${formatPostFoldFailureHint(foldedDocs, "uncommitted", baseBranch)}` : "";
|
|
8602
|
+
die2(`--ship aborted: failed to commit archive changes: ${archiveCommit.stderr}${hint}`);
|
|
8389
8603
|
}
|
|
8390
8604
|
for (const branch of localBranchesToDelete) {
|
|
8391
8605
|
const result = gitSafe("branch", "-D", branch);
|
|
@@ -8441,7 +8655,7 @@ function rerouteFromHumanReview(taskIds) {
|
|
|
8441
8655
|
if (!result.amended) {
|
|
8442
8656
|
amendmentFailures.push({
|
|
8443
8657
|
taskId,
|
|
8444
|
-
specPath:
|
|
8658
|
+
specPath: path22.join(taskDirFor2(taskId), "spec.md"),
|
|
8445
8659
|
requiredRound,
|
|
8446
8660
|
expectedHeading: requiredRound === 1 ? "## Amendment" : `## Amendment Round ${requiredRound}`,
|
|
8447
8661
|
reason: result.reason
|
|
@@ -8497,7 +8711,7 @@ function rerouteFromHumanReview(taskIds) {
|
|
|
8497
8711
|
}
|
|
8498
8712
|
const rescaffold = rescaffoldReview(taskDirFor2(taskId), scaffoldIdentity);
|
|
8499
8713
|
if (rescaffold.outcome === "written") {
|
|
8500
|
-
info(`Re-scaffolded tasks/${taskId}/review.md from ${
|
|
8714
|
+
info(`Re-scaffolded tasks/${taskId}/review.md from ${path22.relative(process.cwd(), rescaffold.source)}.`);
|
|
8501
8715
|
} else if (rescaffold.outcome === "no-template") {
|
|
8502
8716
|
warn2(
|
|
8503
8717
|
`tasks/${taskId}/review.md is missing and no template was found to re-scaffold it (looked for a review.md override under tasks/_templates/ and .canon/templates/review.md). The code-review foreman will have to write the file from scratch.`
|
|
@@ -8631,7 +8845,7 @@ async function runPhase(phase, state) {
|
|
|
8631
8845
|
if (phase === "qa") {
|
|
8632
8846
|
const activeCwd = getActiveCwd(taskIds);
|
|
8633
8847
|
const qaTemplatePath = state.isBundle ? null : findPullRequestTemplate(activeCwd) ?? findPullRequestTemplate(REPO_ROOT2);
|
|
8634
|
-
const resolvedPrTemplate = qaTemplatePath ?
|
|
8848
|
+
const resolvedPrTemplate = qaTemplatePath ? fs23.readFileSync(qaTemplatePath, "utf8") : null;
|
|
8635
8849
|
return runQaPhase(state, cliArgs.interactive, resolvedPrTemplate);
|
|
8636
8850
|
}
|
|
8637
8851
|
if (phase === "human_review") {
|
|
@@ -8645,7 +8859,7 @@ async function runPhase(phase, state) {
|
|
|
8645
8859
|
}
|
|
8646
8860
|
const branch = [...branches][0];
|
|
8647
8861
|
const cwd = getActiveCwd(taskIds2);
|
|
8648
|
-
const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ??
|
|
8862
|
+
const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path22.join(cwd, "tasks");
|
|
8649
8863
|
for (const taskId of taskIds2) {
|
|
8650
8864
|
const gateResult = checkPhaseGate(taskId, "human_review", void 0, tasksRootForGate);
|
|
8651
8865
|
if (!gateResult.ok) {
|
|
@@ -8712,9 +8926,9 @@ async function runPhase(phase, state) {
|
|
|
8712
8926
|
}
|
|
8713
8927
|
var extractCheckedVerdict2 = extractCheckedVerdict;
|
|
8714
8928
|
function readArtifact(taskId, name) {
|
|
8715
|
-
const p =
|
|
8929
|
+
const p = path22.join(taskDirFor2(taskId), name);
|
|
8716
8930
|
try {
|
|
8717
|
-
return
|
|
8931
|
+
return fs23.readFileSync(p, "utf8");
|
|
8718
8932
|
} catch {
|
|
8719
8933
|
return null;
|
|
8720
8934
|
}
|
|
@@ -8730,15 +8944,15 @@ function checkImplementEvidence(taskId) {
|
|
|
8730
8944
|
return { advanced: false, note: `handoff.md Changes table has malformed row(s): ${sample}${tail}` };
|
|
8731
8945
|
}
|
|
8732
8946
|
const issues = validateHandoffAgainstSpec(
|
|
8733
|
-
|
|
8734
|
-
|
|
8947
|
+
path22.join(taskDirFor2(taskId), "spec.md"),
|
|
8948
|
+
path22.join(taskDirFor2(taskId), "handoff.md")
|
|
8735
8949
|
);
|
|
8736
8950
|
if (issues.length > 0) return { advanced: false, note: `handoff.md validation failed: ${issues.join("; ")}` };
|
|
8737
8951
|
const checkRoots = [REPO_ROOT2];
|
|
8738
8952
|
const sForEvidence = readStatus(taskId);
|
|
8739
8953
|
if (sForEvidence.worktree === true) {
|
|
8740
8954
|
const wt = worktreePath(taskId);
|
|
8741
|
-
if (
|
|
8955
|
+
if (fs23.existsSync(wt)) checkRoots.push(wt);
|
|
8742
8956
|
}
|
|
8743
8957
|
const ignoreCwd = checkRoots[checkRoots.length - 1];
|
|
8744
8958
|
const gitIgnored = filterGitIgnoredPaths(files, ignoreCwd);
|
|
@@ -8750,7 +8964,7 @@ function checkImplementEvidence(taskId) {
|
|
|
8750
8964
|
};
|
|
8751
8965
|
}
|
|
8752
8966
|
const existingFiles = verifiableFiles.filter(
|
|
8753
|
-
(f) => checkRoots.some((root) =>
|
|
8967
|
+
(f) => checkRoots.some((root) => fs23.existsSync(path22.join(root, f)))
|
|
8754
8968
|
);
|
|
8755
8969
|
if (existingFiles.length === 0) {
|
|
8756
8970
|
const evidenceCwd = checkRoots[checkRoots.length - 1];
|
|
@@ -8835,7 +9049,7 @@ function tryEvidenceAdvance(taskId, phase) {
|
|
|
8835
9049
|
return { advanced: true, note: "spec.md is populated" };
|
|
8836
9050
|
}
|
|
8837
9051
|
case "qa": {
|
|
8838
|
-
const donePath =
|
|
9052
|
+
const donePath = path22.join(taskDirFor(taskId), "done.md");
|
|
8839
9053
|
if (isDoneMdTemplate(donePath)) return { advanced: false, note: "done.md is still the template" };
|
|
8840
9054
|
taskPhase(taskId, "qa", "done");
|
|
8841
9055
|
return { advanced: true, note: "done.md is populated" };
|
|
@@ -9186,9 +9400,9 @@ function checkDeps(taskIds, skipAgentDeps = false) {
|
|
|
9186
9400
|
}
|
|
9187
9401
|
for (const taskId of taskIds) {
|
|
9188
9402
|
validateTaskId(taskId);
|
|
9189
|
-
const repoRootStatusFile =
|
|
9190
|
-
const statusFile = cliArgs.ship &&
|
|
9191
|
-
if (!
|
|
9403
|
+
const repoRootStatusFile = path22.join(REPO_ROOT2, "tasks", taskId, "status.json");
|
|
9404
|
+
const statusFile = cliArgs.ship && fs23.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
|
|
9405
|
+
if (!fs23.existsSync(statusFile)) {
|
|
9192
9406
|
die(`No status.json at tasks/${taskId}/status.json \u2014 run canon task new ${taskId} first`);
|
|
9193
9407
|
}
|
|
9194
9408
|
}
|
|
@@ -9233,8 +9447,8 @@ async function main() {
|
|
|
9233
9447
|
const earlyHeartbeatTaskIds = cliArgs.taskIds;
|
|
9234
9448
|
let heartbeatStarted = false;
|
|
9235
9449
|
const earlyHeartbeatResolver = (id) => {
|
|
9236
|
-
const repoRootStatusFile =
|
|
9237
|
-
return
|
|
9450
|
+
const repoRootStatusFile = path22.join(REPO_ROOT2, "tasks", id, "status.json");
|
|
9451
|
+
return path22.dirname(cliArgs.ship && fs23.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
|
|
9238
9452
|
};
|
|
9239
9453
|
if (!cliArgs.ship && !cliArgs.dryRun) {
|
|
9240
9454
|
guardConcurrentRun(cliArgs.taskIds, earlyHeartbeatResolver);
|
|
@@ -9270,7 +9484,7 @@ async function main() {
|
|
|
9270
9484
|
}
|
|
9271
9485
|
refreshCanonSnapshotsAtPaths(taskIds.map(statusFileFor));
|
|
9272
9486
|
const initialState = buildPipelineState(taskIds);
|
|
9273
|
-
const heartbeatDirResolver = (id) =>
|
|
9487
|
+
const heartbeatDirResolver = (id) => path22.dirname(statusFileFor(id));
|
|
9274
9488
|
if (!isSynchronousMode(cliArgs) && shouldAutoDetach()) {
|
|
9275
9489
|
detachAndExit({
|
|
9276
9490
|
taskIds,
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Notes
|
|
2
2
|
|
|
3
|
-
Raw observations from any phase. Prefix with phase name. Distilled into `
|
|
3
|
+
Raw observations from any phase. Prefix with phase name. Distilled into the task’s `lessons.md` fragment during QA; `--ship` folds it into `docs/lessons-learned.md`.
|
|
4
4
|
|
|
5
5
|
<!-- Append below this line -->
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Lessons Learned
|
|
2
2
|
|
|
3
|
-
> Distilled cross-task insights.
|
|
3
|
+
> Distilled cross-task insights. Task runs write lessons fragments; `canon run --ship` folds them here when a task surfaces a reusable insight that would have changed how a *different* task was approached.
|
|
4
4
|
|
|
5
5
|
## How to use this doc
|
|
6
6
|
|
|
7
7
|
This file accumulates over the lifetime of the project. Entries land here from two paths:
|
|
8
8
|
|
|
9
|
-
1. **QA distillation (automated, append-only)**: At the end of each task, the QA phase reads `tasks/<id>/notes.md` (raw scratchpad observations from any phase) and asks "would this have changed how a *different* task was approached?" If yes, it **appends** one entry
|
|
9
|
+
1. **QA distillation (automated, append-only)**: At the end of each task, the QA phase reads `tasks/<id>/notes.md` (raw scratchpad observations from any phase) and asks "would this have changed how a *different* task was approached?" If yes, it **appends** one entry to that task's `lessons.md` fragment; `--ship` folds it into this shared doc. If no, the detail stays in `notes.md` only. QA never edits, prunes, promotes, or reorganizes existing entries — appending its own task's fragment is its only lessons write. Task runs never write this shared doc directly.
|
|
10
10
|
|
|
11
|
-
2. **Lessons sweep (human-initiated and human-approved only)**: Promoting entries into permanent docs and pruning the buffer is a **human decision**. Agents never perform it autonomously, and no entry count ever auto-triggers it. Two occasions call for a sweep: (a) when this file
|
|
11
|
+
2. **Lessons sweep (human-initiated and human-approved only)**: Promoting entries into permanent docs and pruning the buffer is a **human decision**. Agents never perform it autonomously, and no entry count ever auto-triggers it. Two occasions call for a sweep: (a) when this file plus the current task's lessons fragment exceed ~15 entries — the QA phase notices and *signals* it with a one-line note in the task's `done.md`, but does not act; and (b) at the end of a release milestone — a human-recognized occasion, since the QA phase has no notion of release boundaries and emits no signal for it. Either way, a human runs the sweep when they choose to:
|
|
12
12
|
- **Promote durable truths** to the right permanent doc — `docs/patterns.md` (a pitfall), `docs/decisions.md` (a settled decision), or `AGENTS.md` / `CLAUDE.md` (a workflow / spec / review rule). Then prune the entry here.
|
|
13
13
|
- **Prune task-specific entries** that turned out not to generalize. Just delete them — the detail lives in the task's `notes.md` and the git history of this file.
|
|
14
14
|
|
|
@@ -72,7 +72,7 @@ Never add a suppression comment without a written justification on the same line
|
|
|
72
72
|
|
|
73
73
|
> Hard-won lessons from past mistakes. Violating them causes subtle bugs.
|
|
74
74
|
>
|
|
75
|
-
> **TODO[canon]: This is where the project's accumulated wisdom lives. Each pitfall should follow the format below. Initially empty for new projects — entries land here when a human promotes durable lessons from `docs/lessons-learned.md` during a sweep (see that file → "How to use this doc"). QA only appends to the buffer; it never promotes into this doc.**
|
|
75
|
+
> **TODO[canon]: This is where the project's accumulated wisdom lives. Each pitfall should follow the format below. Initially empty for new projects — entries land here when a human promotes durable lessons from `docs/lessons-learned.md` during a sweep (see that file → "How to use this doc"). QA only appends lessons (to its task's fragment, which `--ship` folds into the buffer); it never promotes into this doc.**
|
|
76
76
|
|
|
77
77
|
### Pitfall format
|
|
78
78
|
|
|
@@ -297,7 +297,7 @@ With `"worktree": true` in `status.json` — the scaffolded default from `canon
|
|
|
297
297
|
|
|
298
298
|
**Main repo stays on its base**: In worktree mode, the orchestrator creates the `task/<id>` branch directly in the worktree. The main repo never checks out the task branch.
|
|
299
299
|
|
|
300
|
-
**Scaffold-to-base before worktree creation**: On the first `implement` phase call (when no task branch exists yet), the orchestrator commits the full `tasks/<id>/` scaffold — every artifact stub plus `status.json` — to the base branch with message `task(<id>): commit artifacts pre-pipeline`. If `PIPELINE_TELEMETRY_FILES` are dirty, a sibling commit follows: `chore: absorb pre-implement telemetry into scaffold for <id>`. Then `ensureBranch` creates the `task/<id>` branch from base, so the new worktree inherits the scaffold via its initial checkout. Without this, the worktree would boot from a base branch that has no `tasks/<id>/` and pipeline phases would have nothing to read. On re-runs of `implement` (reroutes, review iterations) the worktree already exists and the scaffold-to-base commit is skipped — re-committing it would create divergent commits that fight with the task branch's evolved artifacts at PR-merge time.
|
|
300
|
+
**Scaffold-to-base before worktree creation**: On the first `implement` phase call (when no task branch exists yet), the orchestrator commits the full `tasks/<id>/` scaffold — every artifact stub plus `status.json` — to the base branch with message `task(<id>): commit artifacts pre-pipeline`. If `PIPELINE_TELEMETRY_FILES` are dirty (legacy rows left by an older engine; current pre-implement metrics fragments ride the task scaffold commit), a sibling commit follows: `chore: absorb pre-implement telemetry into scaffold for <id>`. Then `ensureBranch` creates the `task/<id>` branch from base, so the new worktree inherits the scaffold via its initial checkout. Without this, the worktree would boot from a base branch that has no `tasks/<id>/` and pipeline phases would have nothing to read. On re-runs of `implement` (reroutes, review iterations) the worktree already exists and the scaffold-to-base commit is skipped — re-committing it would create divergent commits that fight with the task branch's evolved artifacts at PR-merge time.
|
|
301
301
|
|
|
302
302
|
**Dirty source guard before first worktree**: Before the first worktree is created, the orchestrator inspects `REPO_ROOT` with `git status --porcelain=v1 -uall`. Dirtiness under `tasks/` and `PIPELINE_TELEMETRY_FILES` is allowed because canon owns those scaffold/telemetry paths. Any tracked or untracked source path outside that allow-list aborts worktree creation: commit or stash intentional edits before starting, or rerun with `--force` if the new task should intentionally start from `base_branch` without those local edits.
|
|
303
303
|
|
|
@@ -333,7 +333,7 @@ When `qa.status` reaches `done`, the orchestrator commits QA artifacts before st
|
|
|
333
333
|
At `human_review` with `--push` or `--pr`, the orchestrator auto-commits a scoped allow-list before pushing:
|
|
334
334
|
|
|
335
335
|
- **`tasks/<id>/`** — task artifacts (spec, plan, handoff, review, done, pr-body, notes).
|
|
336
|
-
- **`PIPELINE_TELEMETRY_FILES`** (`docs/lessons-learned.md`, `docs/task-quality-log.md`, `docs/pipeline-invocations.md`) —
|
|
336
|
+
- **`PIPELINE_TELEMETRY_FILES`** (`docs/lessons-learned.md`, `docs/task-quality-log.md`, `docs/pipeline-invocations.md`) — legacy edits remain auto-committed. New task runs write `metrics.md`, `quality-log.md`, and `lessons.md` fragments in each task directory; `--ship` folds them into the shared logs before archiving and deletes the fragments. Runtime writes are fail-soft. Missing fragments are a no-op; fold failures abort before archive-state changes. `--pr` warns on shared-log edits because parallel tasks will conflict there.
|
|
337
337
|
- **Admitted docs** — `(protected ∪ mapped) − flag_only`, resolved from the project's docs-freshness config (config.json in the .canon directory). The protected set defaults to architecture, codebase-map, patterns, product-context, and decisions; `protected` replaces it. All docs in `paths` values are mapped docs. Admission depends only on config, never on the diff. Affected Files admits protected, mapped, or flag-only docs explicitly assigned to the implementer. **QA-done widening** adds all admitted docs once any task's `qa.status === 'done'`. QA's prompt set is the protected set plus docs mapped from globs matching the task's rename-aware three-dot diff, excluding task artifacts and telemetry. Flag-only docs in that prompt set are reported in done.md's "Docs drift" section instead of edited. QA only corrects stale references; it does not promote lessons into permanent docs. Malformed config stops every consumer without falling back to defaults. Run-start validation precedes heartbeat, status changes, reroute, and ship actions; unknown keys warn. Every form reads the active checkout except `--ship`, which reads the main checkout.
|
|
338
338
|
|
|
339
339
|
The `--pr` body resolution order is `CANON_PR_BODY` → populated `tasks/<id>/pr-body.md` (single task only) → repo PR template file → `--fill`. Bundles log that per-task QA bodies are not combined and fall back to the template/`--fill` path in this version.
|
|
@@ -506,9 +506,11 @@ The reroute amendment convention is asymmetric: round 1 accepts a bare `## Amend
|
|
|
506
506
|
|
|
507
507
|
**Note on merge strategy**: `--ship` uses `gh pr merge --squash` as canon's default merge strategy. Projects that use rebase-merge or merge-commit should handle the merge outside canon, then run `--ship` afterward so it can detect the merged PR and proceed with cleanup.
|
|
508
508
|
|
|
509
|
-
**Shared-doc dirt at `--ship`**: when at least one shipped task is worktree-mode, the supervising checkout can carry uncommitted shared-doc dirt, most commonly
|
|
509
|
+
**Shared-doc dirt at `--ship`**: when at least one shipped task is worktree-mode, the supervising checkout can carry uncommitted shared-doc dirt, most commonly telemetry rows an older engine appended. Dirty configured docs (protected, mapped, or flag-only) abort `--ship` before anything merges; the error names each file and instructs commit-or-stash, and `--force` does not bypass this gate. Dirty telemetry files are preserved only when the uncommitted content is a byte-for-byte pure append over the HEAD copy: the suffix is backed up to disk, the working copy is reverted to HEAD, and after the archive changes are staged but before they are committed, the suffix is re-appended as an uncommitted change. Non-append telemetry dirt, or a telemetry file that cannot be read at HEAD, aborts pre-merge like configured-doc dirt. Nothing is silently discarded.
|
|
510
510
|
|
|
511
|
-
`--ship` runs in this order: (1) classify supervising-checkout shared-doc dirt for worktree-mode ships, aborting pre-merge for configured-doc or unsafe telemetry dirt and temporarily preserving safe telemetry appends, (2) verify local `<base>` has no commits ahead of `origin/<base>` unless `--allow-divergent-base` is passed, (3) merge any open PR for the task branch via `gh pr merge --squash --delete-branch`, (4) pull or fast-forward the base branch when needed, (5) run any project-specific post-merge hook under `.canon/hooks/`, (6) prove the task's merge before local branch deletion, (7) `git worktree remove --force` if a worktree was active, (
|
|
511
|
+
`--ship` runs in this order: (1) classify supervising-checkout shared-doc dirt for worktree-mode ships, aborting pre-merge for configured-doc or unsafe telemetry dirt and temporarily preserving safe telemetry appends, (2) verify local `<base>` has no commits ahead of `origin/<base>` unless `--allow-divergent-base` is passed, (3) merge any open PR for the task branch via `gh pr merge --squash --delete-branch`, (4) pull or fast-forward the base branch when needed, (5) run any project-specific post-merge hook under `.canon/hooks/`, (6) prove the task's merge before local branch deletion, (7) fold task telemetry fragments into the three shared logs in argument order and remove the fragments, aborting on failure before any archive-state change, (8) `git worktree remove --force` if a worktree was active, (9) archive `tasks/<id>/` to `tasks/_archive/<id>/` in the main checkout, (10) stage archive changes plus all three shared telemetry docs, re-append preserved telemetry dirt as uncommitted supervising-checkout changes, commit and push the archive changes, and clean up local branches. **`--ship` fails closed if `handoff.md` is missing** — a task cannot be archived without validation evidence. Similarly, closing `human_review` without a `handoff.md` present fails with an explicit error rather than silently succeeding.
|
|
512
|
+
|
|
513
|
+
**Failure after the telemetry fold**: fragment deletion prevents a second fold, but does not make later archive, commit, or push failures automatically recoverable. Preserve the folded shared docs and any completed archive moves; do not reset or check out the shared docs, since the consumed fragments cannot regenerate those rows. Inspect the working tree and index, resolve the reported archive/commit/push problem, and commit and push the intended folded docs and archive changes on the declared base branch, keeping any sibling telemetry out of that commit. Complete any remaining task/archive cleanup manually as needed. Before retrying still-unarchived tasks in worktree mode, preserve their already-folded docs in a commit: the dirty-doc guard may refuse quality-row edits or classify appended metrics/lessons as sibling dirt. Do not assume a plain `--ship` retry will finish a partially archived task. If only the push failed, the `chore: archive` commit already holds the folded docs and archive moves locally: fix the push problem and run `git push origin <base>`; do not reset the local base or re-run the fold.
|
|
512
514
|
|
|
513
515
|
**Forge-proof deletion gate**: before deleting any local task branch, `--ship` requires positive merge evidence. When the `--pr` path has run, the PR number is stored in a gitignored task-local sidecar (`tasks/<id>/.pr-number`); proof requires the pinned PR to be `MERGED`, the PR `baseRefName` to match the task's `base_branch`, and the local task-branch tip to be an ancestor of, or equal to, the PR `headRefOid`. When the sidecar is absent (tasks created before v1.11, or a worktree rebuilt without `--pr`), `--ship` falls back to a base-filtered merged-PR branch lookup, still requiring the local tip to be ancestor-or-equal to the PR head. If the PR head object cannot be materialized locally, proof is unestablished and `--ship` fails closed. If the local task branch is already absent, no proof is required because there is no local branch deletion left to protect. `--force` does not bypass this proof gate; the failure message names the manual recovery path.
|
|
514
516
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Task Quality Log
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Task runs upsert a quality-log.md fragment at the qa → done phase transition; `canon run --ship` folds it here — one row per task, keyed by Task id. QA supplies five judgment cells via done.md; the remaining cells are derived from status.json.
|
|
4
4
|
|
|
5
5
|
## How to use this doc
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ Each row records what *actually happened* in a task — spec verdicts, review it
|
|
|
10
10
|
- Repeated dropped ACs in code review? → Codex prompts or AC clarity need work.
|
|
11
11
|
- Validation gaps slipping through to human_review? → Validation matrix or handoff template needs work.
|
|
12
12
|
|
|
13
|
-
The qa → done transition writes or updates this task's row
|
|
13
|
+
The qa → done transition writes or updates this task's quality-log.md fragment; `--ship` folds that row into this shared log. The product owner reviews trends periodically (e.g., end of release milestone) and uses signals to update `AGENTS.md`, prompts, or templates.
|
|
14
14
|
|
|
15
15
|
## Columns
|
|
16
16
|
|