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 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
- ## [Unreleased]
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 logRows = parseLogRows(
986
- lines,
987
- located.headerCells,
988
- located.dataStart,
989
- located.dataEnd
990
- );
991
- const strayRows = parseStrayRows(lines, located.headerCells);
992
- const taskRows = [...logRows, ...strayRows].filter((row) => (row.cells.Task ?? "").trim() === derived.taskId);
993
- const reconciled = reconcileHistory(taskRows, located.headerCells);
994
- const finalRow = buildFinalRow(located.headerCells, derived, reconciled, qaSupplied);
995
- const rendered = renderRowLine(located.headerCells, finalRow);
996
- const removeIndexes = new Set(taskRows.map((row) => row.lineIndex));
997
- const updatedLines = [];
998
- for (let index = 0; index < lines.length; index += 1) {
999
- if (index === located.dataEnd) updatedLines.push(rendered);
1000
- if (!removeIndexes.has(index)) updatedLines.push(lines[index]);
1001
- }
1002
- if (located.dataEnd >= lines.length) updatedLines.push(rendered);
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, activeCwd, donePath, status) {
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
- getQualityLogFile(activeCwd),
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.9.0";
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 the writer's self-heal write would also fail; create the directory (or run \`canon init\`)`
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 writer creates it fresh on first qa \u2192 done transition"
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.9.0";
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.9.0" ?? "dev";
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.9.0";
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.9.0";
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.9.0");
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 fs22 from "fs";
1844
+ import fs23 from "fs";
1845
1845
  import os from "os";
1846
- import path21 from "path";
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
- function getMetricsFile(activeCwd) {
2108
- return process.env.CANON_METRICS_FILE_OVERRIDE ? path7.resolve(process.env.CANON_METRICS_FILE_OVERRIDE) : path7.join(activeCwd ?? REPO_ROOT, "docs/pipeline-invocations.md");
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
- const metricsFile = getMetricsFile(entry.activeCwd);
2112
- if (!fs8.existsSync(metricsFile)) {
2113
- fs8.writeFileSync(metricsFile, [
2114
- "# Workflow Metrics",
2115
- "",
2116
- "> Auto-logged by canon's orchestrator. One row per agent invocation.",
2117
- "> 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.",
2118
- "",
2119
- "| Timestamp | Task | Phase | Agent | Model | Iter | Duration | Tokens | Status |",
2120
- "|---|---|---|---|---|---|---|---|---|",
2121
- ""
2122
- ].join("\n"));
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 holds more than ~15 entries, add one line to this task\'s done.md \u2014 `Maintenance: lessons-learned.md has 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';
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.9.0" ?? "dev";
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 logRows = parseLogRows(
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, activeCwd, donePath, status) {
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
- getQualityLogFile(activeCwd),
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 = fs22.existsSync(path21.join(cwd, f));
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
- path21.join(cwd, entryPath),
7242
- path21.join(REPO_ROOT2, entryPath)
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 = fs22.realpathSync(cwd);
7250
- repoRootReal = fs22.realpathSync(REPO_ROOT2);
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(path21.join(cwd, workspace), cwd);
7428
+ const resolvedDestination = resolveContainedPath(path22.join(cwd, workspace), cwd);
7258
7429
  if (resolvedDestination === null) continue;
7259
- const relativeDestination = path21.relative(cwdReal, resolvedDestination).split(path21.sep).join("/");
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
- path21.join(resolvedDestination, "node_modules"),
7264
- path21.join(REPO_ROOT2, workspace, "node_modules")
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
- path21.join(repoRoot, ".github", "pull_request_template.md"),
7307
- path21.join(repoRoot, ".github", "PULL_REQUEST_TEMPLATE.md"),
7308
- path21.join(repoRoot, "docs", "pull_request_template.md"),
7309
- path21.join(repoRoot, "docs", "PULL_REQUEST_TEMPLATE.md"),
7310
- path21.join(repoRoot, "pull_request_template.md"),
7311
- path21.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
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 (fs22.existsSync(candidate)) return candidate;
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 = path21.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
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: fs22.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
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 path21.join(taskDir, ".pr-number");
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
- fs22.mkdirSync(path21.dirname(sidecarPath), { recursive: true });
7463
- fs22.writeFileSync(sidecarPath, String(prNum), "utf8");
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 = path21.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
7585
- if (fs22.existsSync(docsRefsScript)) {
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 = fs22.readFileSync(sidecarPath, "utf8").trim();
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, path21.join(taskCwd, "tasks", 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, path21.join(cwd, "tasks", 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 = path21.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
8088
- if (!fs22.existsSync(hookPath)) return;
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
- path21.join(REPO_ROOT2, "docs", "lessons-learned.md"),
8113
- path21.join(REPO_ROOT2, "docs", "task-quality-log.md")
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 (!fs22.existsSync(filePath)) continue;
8117
- let content = fs22.readFileSync(filePath, "utf8");
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
- fs22.writeFileSync(filePath, content, "utf8");
8129
- info2(`Updated stale task refs in ${path21.relative(REPO_ROOT2, filePath)}.`);
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 = fs22.readFileSync(path21.join(REPO_ROOT2, relPath), "utf8");
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 = fs22.mkdtempSync(path21.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
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 = path21.join(backupDir, relPath.replace(/[\\/]/g, "__"));
8185
- fs22.writeFileSync(backupPath, suffix, "utf8");
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 path21.dirname(tasksDirOverride);
8377
+ return path22.dirname(tasksDirOverride);
8200
8378
  }
8201
8379
  if (isOrphanedWorktreeState(taskId)) return REPO_ROOT2;
8202
- return path21.dirname(path21.dirname(taskDirFor2(taskId)));
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
- path21.join(taskCwd, "tasks", taskId, "status.json"),
8209
- path21.join(taskCwd, taskId, "status.json"),
8210
- path21.join(taskDirForRepoRoot2(taskId), "status.json")
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 (fs22.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
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 ?? path21.join(taskCwd, "tasks");
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) => path21.join("tasks", taskId, "status.json"));
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
- const archiveDir = path21.join(TASKS_DIR2, "_archive");
8354
- if (!fs22.existsSync(archiveDir)) fs22.mkdirSync(archiveDir, { recursive: true });
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
- for (const taskId of taskIds) {
8357
- const { worktree: hasWorktree } = taskSnapshot(taskId);
8358
- const status = readShipStatus(taskId);
8359
- if (hasWorktree) teardownWorktree(taskId);
8360
- status.updated = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
8361
- const humanReview = status.phases.human_review;
8362
- if (humanReview) humanReview.status = "done";
8363
- writeStatusToFile(path21.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
8364
- const src = taskDirForRepoRoot2(taskId);
8365
- const dest = path21.join(archiveDir, taskId);
8366
- fs22.renameSync(src, dest);
8367
- info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
8368
- const branchName = taskSnapshot(taskId).branch;
8369
- if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
8370
- }
8371
- rewriteArchivedTaskRefs(taskIds);
8372
- const stagedPaths = taskIds.flatMap((id) => [
8373
- path21.join(TASKS_DIR2, id),
8374
- // deleted source (if not cleaned up)
8375
- path21.join(TASKS_DIR2, "_archive", id),
8376
- // new archive destination
8377
- path21.join(REPO_ROOT2, "docs", "lessons-learned.md"),
8378
- path21.join(REPO_ROOT2, "docs", "task-quality-log.md")
8379
- ]);
8380
- stageArchiveChanges(stagedPaths);
8381
- for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
8382
- fs22.appendFileSync(path21.join(REPO_ROOT2, relPath), suffix, "utf8");
8383
- fs22.rmSync(backupPath, { force: true });
8384
- info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
8385
- }
8386
- const archiveCommit = commitArchiveChanges(taskIds, baseBranch);
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
- die2(`--ship aborted: failed to commit archive changes: ${archiveCommit.stderr}`);
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: path21.join(taskDirFor2(taskId), "spec.md"),
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 ${path21.relative(process.cwd(), rescaffold.source)}.`);
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 ? fs22.readFileSync(qaTemplatePath, "utf8") : null;
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 ?? path21.join(cwd, "tasks");
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 = path21.join(taskDirFor2(taskId), name);
8929
+ const p = path22.join(taskDirFor2(taskId), name);
8716
8930
  try {
8717
- return fs22.readFileSync(p, "utf8");
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
- path21.join(taskDirFor2(taskId), "spec.md"),
8734
- path21.join(taskDirFor2(taskId), "handoff.md")
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 (fs22.existsSync(wt)) checkRoots.push(wt);
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) => fs22.existsSync(path21.join(root, f)))
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 = path21.join(taskDirFor(taskId), "done.md");
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 = path21.join(REPO_ROOT2, "tasks", taskId, "status.json");
9190
- const statusFile = cliArgs.ship && fs22.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
9191
- if (!fs22.existsSync(statusFile)) {
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 = path21.join(REPO_ROOT2, "tasks", id, "status.json");
9237
- return path21.dirname(cliArgs.ship && fs22.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
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) => path21.dirname(statusFileFor(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,6 +1,6 @@
1
1
  {
2
2
  "name": "canon-ai",
3
- "version": "3.9.0",
3
+ "version": "3.10.1",
4
4
  "type": "module",
5
5
  "description": "A spec-first, multi-agent AI coding pipeline you drop into any repo. The law your agents work under.",
6
6
  "engines": {
@@ -1,5 +1,5 @@
1
1
  # Notes
2
2
 
3
- Raw observations from any phase. Prefix with phase name. Distilled into `docs/lessons-learned.md` during QA.
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. The QA phase appends entries here when a task surfaces a reusable insight that would have changed how a *different* task was approached.
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 for that task. If no, the detail stays in `notes.md` only. QA never edits, prunes, promotes, or reorganizes existing entries — appending its own task's entry is the only write it makes to this file.
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 exceeds ~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:
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`) — always auto-committed.
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 a sibling task's pre-implement telemetry rows. 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.
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, (8) archive `tasks/<id>/` to `tasks/_archive/<id>/` in the main checkout, (9) stage archive changes, 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.
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
- > Upserted by the qa → done phase transition — one row per task, keyed by Task id. QA supplies five judgment cells via done.md; the remaining cells are derived from status.json.
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 in place. The product owner reviews trends periodically (e.g., end of release milestone) and uses signals to update `AGENTS.md`, prompts, or templates.
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