canon-ai 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.10.0] — 2026-10-17
8
+
9
+ ### Changed
10
+
11
+ - **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.
12
+
13
+ ### Added
14
+
15
+ - **`--pr` warns when a task branch still edits a shared telemetry log.** The warning names each file and doesn't block the PR.
16
+ - **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.
17
+
7
18
  ## [3.9.0] — 2026-10-16
8
19
 
9
20
  ### Added
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.0";
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.0";
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.0" ?? "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.0";
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.0";
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.0");
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. 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.0" ?? "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,131 @@ 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 foldLessonsFragment(shared, fragment) {
6888
+ const body = fragment.trim();
6889
+ if (!body) return null;
6890
+ const base = (shared ?? LESSONS_SKELETON).trimEnd();
6891
+ const separator = base.endsWith("---") || body.startsWith("---") ? "\n\n" : "\n\n---\n\n";
6892
+ return `${base}${separator}${body}
6893
+ `;
6894
+ }
6895
+ function sharedTelemetryDocsTouched(paths) {
6896
+ return PIPELINE_TELEMETRY_FILES.filter((file) => paths.includes(file));
6897
+ }
6898
+ function readOptional(file) {
6899
+ try {
6900
+ return fs22.readFileSync(file, "utf8");
6901
+ } catch (error) {
6902
+ if (error.code === "ENOENT") return null;
6903
+ throw error;
6904
+ }
6905
+ }
6906
+ function planTelemetryFold(repoRoot, taskIds, taskDirFor3) {
6907
+ const kinds = [
6908
+ { name: "metrics.md", target: getSharedMetricsFile(repoRoot), fold: foldMetricsFragment },
6909
+ { name: "quality-log.md", target: path21.join(repoRoot, "docs/task-quality-log.md"), fold: foldQualityLogFragment },
6910
+ { name: "lessons.md", target: path21.join(repoRoot, "docs/lessons-learned.md"), fold: foldLessonsFragment }
6911
+ ];
6912
+ const before = new Map(kinds.map((kind) => [kind.target, readOptional(kind.target)]));
6913
+ const contents = new Map(before);
6914
+ const fragments = [];
6915
+ for (const taskId of taskIds) {
6916
+ for (const kind of kinds) {
6917
+ const fragmentPath = path21.join(taskDirFor3(taskId), kind.name);
6918
+ const content = readOptional(fragmentPath);
6919
+ if (content === null) continue;
6920
+ fragments.push({ path: fragmentPath, content });
6921
+ try {
6922
+ const folded = kind.fold(contents.get(kind.target) ?? null, content, taskId);
6923
+ if (folded !== null) contents.set(kind.target, folded);
6924
+ } catch (error) {
6925
+ throw new Error(`${fragmentPath}: ${error instanceof Error ? error.message : String(error)}`);
6926
+ }
6927
+ }
6928
+ }
6929
+ return {
6930
+ writes: [...contents].flatMap(([target, after]) => after !== null && after !== before.get(target) ? [{ target, before: before.get(target) ?? null, after }] : []),
6931
+ fragments
6932
+ };
6933
+ }
6934
+ function applyTelemetryFold(plan) {
6935
+ const temps = [];
6936
+ const written = [];
6937
+ const deleted = [];
6938
+ try {
6939
+ for (const write of plan.writes) {
6940
+ fs22.mkdirSync(path21.dirname(write.target), { recursive: true });
6941
+ const temp = `${write.target}.${randomUUID()}.canon-fold.tmp`;
6942
+ const fd = fs22.openSync(temp, "wx");
6943
+ temps.push(temp);
6944
+ try {
6945
+ fs22.writeFileSync(fd, write.after, "utf8");
6946
+ } finally {
6947
+ fs22.closeSync(fd);
6948
+ }
6949
+ }
6950
+ for (let index = 0; index < plan.writes.length; index += 1) {
6951
+ fs22.renameSync(temps[index], plan.writes[index].target);
6952
+ written.push(plan.writes[index]);
6953
+ }
6954
+ for (const fragment of plan.fragments) {
6955
+ fs22.unlinkSync(fragment.path);
6956
+ deleted.push(fragment);
6957
+ }
6958
+ } catch (error) {
6959
+ const rollbackErrors = [];
6960
+ const restore = (action) => {
6961
+ try {
6962
+ action();
6963
+ } catch (rollbackError) {
6964
+ rollbackErrors.push(String(rollbackError));
6965
+ }
6966
+ };
6967
+ for (const write of written.reverse()) {
6968
+ restore(() => {
6969
+ if (write.before === null) fs22.unlinkSync(write.target);
6970
+ else fs22.writeFileSync(write.target, write.before, "utf8");
6971
+ });
6972
+ }
6973
+ for (const fragment of deleted) restore(() => fs22.writeFileSync(fragment.path, fragment.content, "utf8"));
6974
+ if (rollbackErrors.length) throw new Error(`${String(error)}; rollback failed: ${rollbackErrors.join("; ")}`);
6975
+ throw error;
6976
+ } finally {
6977
+ for (const temp of temps) {
6978
+ try {
6979
+ fs22.unlinkSync(temp);
6980
+ } catch {
6981
+ }
6982
+ }
6983
+ }
6984
+ }
6985
+ function foldTelemetryFragments(repoRoot, taskIds, taskDirFor3) {
6986
+ const plan = planTelemetryFold(repoRoot, taskIds, taskDirFor3);
6987
+ applyTelemetryFold(plan);
6988
+ return plan.writes.map((write) => path21.relative(repoRoot, write.target));
6989
+ }
6990
+ function formatPostFoldFailureHint(foldedDocs, state, baseBranch) {
6991
+ 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.`;
6992
+ 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.`;
6993
+ }
6994
+
6831
6995
  // src/orchestrator/main.ts
6832
6996
  var REPO_ROOT2 = REPO_ROOT;
6833
6997
  var TASKS_DIR2 = TASKS_DIR;
@@ -7089,7 +7253,7 @@ function autoCommitCode(taskIds, cwd = REPO_ROOT2) {
7089
7253
  for (const f of allHandoffFiles) {
7090
7254
  if (dirtyFiles.has(f)) continue;
7091
7255
  if (gitIgnoredHandoffFiles.has(f)) continue;
7092
- const exists = fs22.existsSync(path21.join(cwd, f));
7256
+ const exists = fs23.existsSync(path22.join(cwd, f));
7093
7257
  if (!exists) {
7094
7258
  const committed = gitSafeAt(cwd, "log", "--format=%H", "--max-count=1", `${baseRefForLog}..HEAD`, "--", f);
7095
7259
  if (committed.ok && committed.stdout.trim()) {
@@ -7238,30 +7402,30 @@ function exemptNodeModulesPath(entry, cwd, resolvedWorkspaceDirs) {
7238
7402
  if (entryPath === null) return null;
7239
7403
  if (matchesPorcelainPath(entryPath, "node_modules")) {
7240
7404
  return probeNodeModulesEntry(
7241
- path21.join(cwd, entryPath),
7242
- path21.join(REPO_ROOT2, entryPath)
7405
+ path22.join(cwd, entryPath),
7406
+ path22.join(REPO_ROOT2, entryPath)
7243
7407
  ).verdict === "verified-symlink" ? "node_modules" : null;
7244
7408
  }
7245
7409
  if (!isNodeModulesEntryPath(entryPath)) return null;
7246
7410
  let cwdReal;
7247
7411
  let repoRootReal;
7248
7412
  try {
7249
- cwdReal = fs22.realpathSync(cwd);
7250
- repoRootReal = fs22.realpathSync(REPO_ROOT2);
7413
+ cwdReal = fs23.realpathSync(cwd);
7414
+ repoRootReal = fs23.realpathSync(REPO_ROOT2);
7251
7415
  } catch {
7252
7416
  return null;
7253
7417
  }
7254
7418
  if (cwdReal === repoRootReal) return null;
7255
7419
  const workspaceDirs = resolvedWorkspaceDirs ?? resolveWorkspaceDirs(REPO_ROOT2);
7256
7420
  for (const workspace of workspaceDirs) {
7257
- const resolvedDestination = resolveContainedPath(path21.join(cwd, workspace), cwd);
7421
+ const resolvedDestination = resolveContainedPath(path22.join(cwd, workspace), cwd);
7258
7422
  if (resolvedDestination === null) continue;
7259
- const relativeDestination = path21.relative(cwdReal, resolvedDestination).split(path21.sep).join("/");
7423
+ const relativeDestination = path22.relative(cwdReal, resolvedDestination).split(path22.sep).join("/");
7260
7424
  const expectedEntryPath = `${relativeDestination}/node_modules`;
7261
7425
  if (!matchesPorcelainPath(entryPath, expectedEntryPath)) continue;
7262
7426
  return probeNodeModulesEntry(
7263
- path21.join(resolvedDestination, "node_modules"),
7264
- path21.join(REPO_ROOT2, workspace, "node_modules")
7427
+ path22.join(resolvedDestination, "node_modules"),
7428
+ path22.join(REPO_ROOT2, workspace, "node_modules")
7265
7429
  ).verdict === "verified-symlink" ? expectedEntryPath : null;
7266
7430
  }
7267
7431
  return null;
@@ -7303,15 +7467,15 @@ function buildHumanReviewStagePaths(taskIds, affectedManagedDocs, dirtyEntries,
7303
7467
  }
7304
7468
  function findPullRequestTemplate(repoRoot) {
7305
7469
  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")
7470
+ path22.join(repoRoot, ".github", "pull_request_template.md"),
7471
+ path22.join(repoRoot, ".github", "PULL_REQUEST_TEMPLATE.md"),
7472
+ path22.join(repoRoot, "docs", "pull_request_template.md"),
7473
+ path22.join(repoRoot, "docs", "PULL_REQUEST_TEMPLATE.md"),
7474
+ path22.join(repoRoot, "pull_request_template.md"),
7475
+ path22.join(repoRoot, "PULL_REQUEST_TEMPLATE.md")
7312
7476
  ];
7313
7477
  for (const candidate of candidates) {
7314
- if (fs22.existsSync(candidate)) return candidate;
7478
+ if (fs23.existsSync(candidate)) return candidate;
7315
7479
  }
7316
7480
  return null;
7317
7481
  }
@@ -7325,13 +7489,13 @@ function resolveQaPrBody(taskIds, activeCwd) {
7325
7489
  if (taskIds.length !== 1) {
7326
7490
  return { kind: "fallback", reason: "bundle: per-task pr-body.md files are not combined in this version" };
7327
7491
  }
7328
- const prBodyPath = path21.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
7492
+ const prBodyPath = path22.join(activeCwd, "tasks", taskIds[0], "pr-body.md");
7329
7493
  if (!isPrBodyTemplate(prBodyPath)) {
7330
7494
  return { kind: "body-file", path: prBodyPath };
7331
7495
  }
7332
7496
  return {
7333
7497
  kind: "fallback",
7334
- reason: fs22.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
7498
+ reason: fs23.existsSync(prBodyPath) ? "pr-body.md is still the stub template" : "pr-body.md not found"
7335
7499
  };
7336
7500
  }
7337
7501
  function commitQaArtifacts(taskIds, cwd) {
@@ -7452,20 +7616,27 @@ function formatExistingPRMessage(prNum, prUrl) {
7452
7616
  return `Existing draft PR: #${prNum} (${prUrl})`;
7453
7617
  }
7454
7618
  function sidecarPathFor(taskId, taskDir = taskDirFor2(taskId)) {
7455
- return path21.join(taskDir, ".pr-number");
7619
+ return path22.join(taskDir, ".pr-number");
7456
7620
  }
7457
7621
  function recordPinnedPRNumber(taskIds, prNum) {
7458
7622
  const alreadyPinned = taskIds.every((taskId) => readSidecarPRNumber(taskId) === prNum);
7459
7623
  if (alreadyPinned) return;
7460
7624
  for (const taskId of taskIds) {
7461
7625
  const sidecarPath = sidecarPathFor(taskId);
7462
- fs22.mkdirSync(path21.dirname(sidecarPath), { recursive: true });
7463
- fs22.writeFileSync(sidecarPath, String(prNum), "utf8");
7626
+ fs23.mkdirSync(path22.dirname(sidecarPath), { recursive: true });
7627
+ fs23.writeFileSync(sidecarPath, String(prNum), "utf8");
7464
7628
  }
7465
7629
  }
7466
- function reportOrCreatePR(taskIds, branchName) {
7630
+ function reportOrCreatePR(taskIds, branchName, cwd) {
7467
7631
  if (!ghAvailable) die2("--pr requires the gh CLI, but it is not available.");
7468
7632
  const baseBranch = getBaseBranch(taskIds);
7633
+ const affected = getAffectedFiles(baseBranch, cwd);
7634
+ if (affected.ok) {
7635
+ const touched = sharedTelemetryDocsTouched(affected.files);
7636
+ if (touched.length) warn2(`Shared telemetry docs changed: ${touched.join(", ")}. Parallel tasks will conflict there; new runs write task fragments that --ship folds.`);
7637
+ } else {
7638
+ warn2(`Could not check shared telemetry doc drift: ${affected.stderr}`);
7639
+ }
7469
7640
  const openPR = findOpenPRNumber(branchName, baseBranch);
7470
7641
  let prNum;
7471
7642
  if (openPR !== null) {
@@ -7581,8 +7752,8 @@ function commitHumanReviewFiles(taskIds, cwd, createPR) {
7581
7752
  ` + baseDivergenceResult.commits.map((commit) => ` ${commit.sha.slice(0, 7)} ${commit.subject}`).join("\n")
7582
7753
  );
7583
7754
  }
7584
- const docsRefsScript = path21.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
7585
- if (fs22.existsSync(docsRefsScript)) {
7755
+ const docsRefsScript = path22.join(REPO_ROOT2, "scripts", "docs-refs-check.mjs");
7756
+ if (fs23.existsSync(docsRefsScript)) {
7586
7757
  const docsRefsResult = spawnSync6("node", [docsRefsScript], {
7587
7758
  cwd,
7588
7759
  stdio: ["ignore", "pipe", "pipe"],
@@ -7681,7 +7852,7 @@ This failure cannot be bypassed with --force.`
7681
7852
  if (!pushResult2.ok) {
7682
7853
  die2(`Human review push failed: ${pushResult2.stderr || "unknown error"}`);
7683
7854
  }
7684
- if (createPR) reportOrCreatePR(taskIds, branchName2);
7855
+ if (createPR) reportOrCreatePR(taskIds, branchName2, cwd);
7685
7856
  return;
7686
7857
  }
7687
7858
  }
@@ -7774,7 +7945,7 @@ If this is a source or test file, it should have been committed during the imple
7774
7945
  if (!pushResult.ok) {
7775
7946
  die2(`Human review push failed: ${pushResult.stderr || "unknown error"}`);
7776
7947
  }
7777
- if (createPR) reportOrCreatePR(taskIds, branchName);
7948
+ if (createPR) reportOrCreatePR(taskIds, branchName, cwd);
7778
7949
  }
7779
7950
  function printDryRunPlan(state) {
7780
7951
  const { tasks } = state;
@@ -7938,7 +8109,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
7938
8109
  const sidecarPath = sidecarPathFor(taskId, taskDir);
7939
8110
  let raw;
7940
8111
  try {
7941
- raw = fs22.readFileSync(sidecarPath, "utf8").trim();
8112
+ raw = fs23.readFileSync(sidecarPath, "utf8").trim();
7942
8113
  } catch {
7943
8114
  return null;
7944
8115
  }
@@ -7949,7 +8120,7 @@ function readSidecarPRNumber(taskId, taskDir = taskDirFor2(taskId)) {
7949
8120
  }
7950
8121
  function resolveProofPRNumberForPrefetch(taskId, branchName, baseBranch, taskCwd) {
7951
8122
  if (!ghAvailable) return null;
7952
- const pinnedPrNum = readSidecarPRNumber(taskId, path21.join(taskCwd, "tasks", taskId));
8123
+ const pinnedPrNum = readSidecarPRNumber(taskId, path22.join(taskCwd, "tasks", taskId));
7953
8124
  if (pinnedPrNum !== null) return pinnedPrNum;
7954
8125
  return findOpenPRNumber(branchName, baseBranch) ?? findMergedPRNumber(branchName, baseBranch);
7955
8126
  }
@@ -7980,7 +8151,7 @@ function establishPRHeadAncestryProof(cwd, prNum, prHead, localTip) {
7980
8151
  return { proven: true };
7981
8152
  }
7982
8153
  function establishMergeProof(taskId, branchName, localTip, baseBranch, cwd, prefetchedHeads) {
7983
- const pinnedPrNum = readSidecarPRNumber(taskId, path21.join(cwd, "tasks", taskId));
8154
+ const pinnedPrNum = readSidecarPRNumber(taskId, path22.join(cwd, "tasks", taskId));
7984
8155
  if (ghAvailable && pinnedPrNum !== null) {
7985
8156
  if (!isPRMerged(pinnedPrNum)) {
7986
8157
  return { proven: false, reason: `Pinned PR #${pinnedPrNum} is not in MERGED state.` };
@@ -8084,8 +8255,8 @@ function mergeOpenPRsAndPull(taskIds, baseBranch, branchByTaskId) {
8084
8255
  return anyMerged;
8085
8256
  }
8086
8257
  function runPostMergeHook() {
8087
- const hookPath = path21.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
8088
- if (!fs22.existsSync(hookPath)) return;
8258
+ const hookPath = path22.join(REPO_ROOT2, ".canon/hooks/post-merge.sh");
8259
+ if (!fs23.existsSync(hookPath)) return;
8089
8260
  info2("Running .canon/hooks/post-merge.sh...");
8090
8261
  const result = runCommand2("bash", [hookPath]);
8091
8262
  if (!result.ok) {
@@ -8109,12 +8280,12 @@ function commitArchiveChanges(taskIds, baseBranch) {
8109
8280
  }
8110
8281
  function rewriteArchivedTaskRefs(taskIds) {
8111
8282
  const targets = [
8112
- path21.join(REPO_ROOT2, "docs", "lessons-learned.md"),
8113
- path21.join(REPO_ROOT2, "docs", "task-quality-log.md")
8283
+ path22.join(REPO_ROOT2, "docs", "lessons-learned.md"),
8284
+ path22.join(REPO_ROOT2, "docs", "task-quality-log.md")
8114
8285
  ];
8115
8286
  for (const filePath of targets) {
8116
- if (!fs22.existsSync(filePath)) continue;
8117
- let content = fs22.readFileSync(filePath, "utf8");
8287
+ if (!fs23.existsSync(filePath)) continue;
8288
+ let content = fs23.readFileSync(filePath, "utf8");
8118
8289
  let changed = false;
8119
8290
  for (const taskId of taskIds) {
8120
8291
  const stale = `tasks/${taskId}/`;
@@ -8125,8 +8296,8 @@ function rewriteArchivedTaskRefs(taskIds) {
8125
8296
  }
8126
8297
  }
8127
8298
  if (changed) {
8128
- fs22.writeFileSync(filePath, content, "utf8");
8129
- info2(`Updated stale task refs in ${path21.relative(REPO_ROOT2, filePath)}.`);
8299
+ fs23.writeFileSync(filePath, content, "utf8");
8300
+ info2(`Updated stale task refs in ${path22.relative(REPO_ROOT2, filePath)}.`);
8130
8301
  }
8131
8302
  }
8132
8303
  }
@@ -8161,7 +8332,7 @@ function classifyAndPreserveSharedDocDirt() {
8161
8332
  if (porcelainCode !== " M") {
8162
8333
  return { relPath, docClass, porcelainCode, headContent: null, workingContent: null };
8163
8334
  }
8164
- const workingContent = fs22.readFileSync(path21.join(REPO_ROOT2, relPath), "utf8");
8335
+ const workingContent = fs23.readFileSync(path22.join(REPO_ROOT2, relPath), "utf8");
8165
8336
  const headResult = gitSafeAtRaw(REPO_ROOT2, "show", `HEAD:${relPath}`);
8166
8337
  return {
8167
8338
  relPath,
@@ -8178,11 +8349,11 @@ function classifyAndPreserveSharedDocDirt() {
8178
8349
  }
8179
8350
  const preserve = verdict.preserve;
8180
8351
  if (preserve.length === 0) return [];
8181
- const backupDir = fs22.mkdtempSync(path21.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
8352
+ const backupDir = fs23.mkdtempSync(path22.join(os.tmpdir(), "canon-ship-shared-doc-backup-"));
8182
8353
  const preserved = [];
8183
8354
  for (const { relPath, suffix } of preserve) {
8184
- const backupPath = path21.join(backupDir, relPath.replace(/[\\/]/g, "__"));
8185
- fs22.writeFileSync(backupPath, suffix, "utf8");
8355
+ const backupPath = path22.join(backupDir, relPath.replace(/[\\/]/g, "__"));
8356
+ fs23.writeFileSync(backupPath, suffix, "utf8");
8186
8357
  info2(`Preserving uncommitted ${relPath} dirt during --ship; backup: ${backupPath}`);
8187
8358
  const checkoutResult = gitSafe("checkout", "HEAD", "--", relPath);
8188
8359
  if (!checkoutResult.ok) {
@@ -8196,21 +8367,21 @@ function shipTasks(taskIds) {
8196
8367
  const resolveShipCwd = (taskId) => {
8197
8368
  const tasksDirOverride = process.env.CANON_TASKS_DIR_OVERRIDE;
8198
8369
  if (tasksDirOverride) {
8199
- return path21.dirname(tasksDirOverride);
8370
+ return path22.dirname(tasksDirOverride);
8200
8371
  }
8201
8372
  if (isOrphanedWorktreeState(taskId)) return REPO_ROOT2;
8202
- return path21.dirname(path21.dirname(taskDirFor2(taskId)));
8373
+ return path22.dirname(path22.dirname(taskDirFor2(taskId)));
8203
8374
  };
8204
8375
  const taskStatuses = /* @__PURE__ */ new Map();
8205
8376
  const readShipStatus = (taskId) => {
8206
8377
  const taskCwd = resolveShipCwd(taskId);
8207
8378
  const candidates = [
8208
- path21.join(taskCwd, "tasks", taskId, "status.json"),
8209
- path21.join(taskCwd, taskId, "status.json"),
8210
- path21.join(taskDirForRepoRoot2(taskId), "status.json")
8379
+ path22.join(taskCwd, "tasks", taskId, "status.json"),
8380
+ path22.join(taskCwd, taskId, "status.json"),
8381
+ path22.join(taskDirForRepoRoot2(taskId), "status.json")
8211
8382
  ];
8212
8383
  for (const candidate of candidates) {
8213
- if (fs22.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
8384
+ if (fs23.existsSync(candidate)) return readStatusFromPath(candidate, taskId);
8214
8385
  }
8215
8386
  const snapshot = taskStatuses.get(taskId);
8216
8387
  if (snapshot) return snapshot;
@@ -8242,7 +8413,7 @@ function shipTasks(taskIds) {
8242
8413
  const currentPhase = getCurrentPhase(readShipStatus(taskId));
8243
8414
  if (currentPhase !== "human_review") continue;
8244
8415
  const taskCwd = resolveShipCwd(taskId);
8245
- const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path21.join(taskCwd, "tasks");
8416
+ const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path22.join(taskCwd, "tasks");
8246
8417
  const gateResult = checkPhaseGate(
8247
8418
  taskId,
8248
8419
  "human_review",
@@ -8274,10 +8445,18 @@ function shipTasks(taskIds) {
8274
8445
  return snapshot;
8275
8446
  };
8276
8447
  let preservedSharedDocDirt = [];
8448
+ const restorePreservedSharedDocDirt = () => {
8449
+ for (const { relPath, suffix, backupPath } of preservedSharedDocDirt) {
8450
+ fs23.appendFileSync(path22.join(REPO_ROOT2, relPath), suffix, "utf8");
8451
+ fs23.rmSync(backupPath, { force: true });
8452
+ info2(`Re-applied preserved ${relPath} dirt as uncommitted changes; backup removed.`);
8453
+ }
8454
+ preservedSharedDocDirt = [];
8455
+ };
8277
8456
  if (taskIds.some((id) => taskSnapshot(id).worktree)) {
8278
8457
  preservedSharedDocDirt = classifyAndPreserveSharedDocDirt();
8279
8458
  }
8280
- const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) => path21.join("tasks", taskId, "status.json"));
8459
+ const orphanedStatusPaths = taskIds.filter((taskId) => taskSnapshot(taskId).worktree && resolveShipCwd(taskId) === REPO_ROOT2).map((taskId) => path22.join("tasks", taskId, "status.json"));
8281
8460
  if (orphanedStatusPaths.length > 0) {
8282
8461
  gitSafe("checkout", "HEAD", "--", ...orphanedStatusPaths);
8283
8462
  }
@@ -8350,42 +8529,70 @@ Recovery:
8350
8529
  - --force does not bypass this gate.`
8351
8530
  );
8352
8531
  }
8353
- const archiveDir = path21.join(TASKS_DIR2, "_archive");
8354
- if (!fs22.existsSync(archiveDir)) fs22.mkdirSync(archiveDir, { recursive: true });
8532
+ let foldedDocs = [];
8533
+ try {
8534
+ foldedDocs = foldTelemetryFragments(REPO_ROOT2, taskIds, taskDirForRepoRoot2);
8535
+ for (const file of foldedDocs) {
8536
+ info2(`Folded task telemetry into ${file}.`);
8537
+ }
8538
+ } catch (error) {
8539
+ restorePreservedSharedDocDirt();
8540
+ 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.`);
8541
+ }
8355
8542
  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);
8543
+ try {
8544
+ const archiveDir = path22.join(TASKS_DIR2, "_archive");
8545
+ if (!fs23.existsSync(archiveDir)) fs23.mkdirSync(archiveDir, { recursive: true });
8546
+ for (const taskId of taskIds) {
8547
+ const { worktree: hasWorktree } = taskSnapshot(taskId);
8548
+ const status = readShipStatus(taskId);
8549
+ if (hasWorktree) teardownWorktree(taskId);
8550
+ status.updated = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
8551
+ const humanReview = status.phases.human_review;
8552
+ if (humanReview) humanReview.status = "done";
8553
+ writeStatusToFile(path22.join(REPO_ROOT2, "tasks", taskId, "status.json"), status);
8554
+ const src = taskDirForRepoRoot2(taskId);
8555
+ const dest = path22.join(archiveDir, taskId);
8556
+ fs23.renameSync(src, dest);
8557
+ info2(`\u{1F4E6} ${taskId} \u2192 tasks/_archive/${taskId}`);
8558
+ const branchName = taskSnapshot(taskId).branch;
8559
+ if (branchExistsLocally(branchName)) localBranchesToDelete.push(branchName);
8560
+ }
8561
+ rewriteArchivedTaskRefs(taskIds);
8562
+ const stagedPaths = taskIds.flatMap((id) => [
8563
+ path22.join(TASKS_DIR2, id),
8564
+ // deleted source (if not cleaned up)
8565
+ path22.join(TASKS_DIR2, "_archive", id),
8566
+ // new archive destination
8567
+ path22.join(REPO_ROOT2, "docs", "lessons-learned.md"),
8568
+ path22.join(REPO_ROOT2, "docs", "task-quality-log.md"),
8569
+ path22.join(REPO_ROOT2, "docs", "pipeline-invocations.md")
8570
+ ]);
8571
+ stageArchiveChanges(stagedPaths);
8572
+ restorePreservedSharedDocDirt();
8573
+ } catch (error) {
8574
+ restorePreservedSharedDocDirt();
8575
+ if (foldedDocs.length === 0) throw error;
8576
+ const message = error instanceof Error ? error.message : String(error);
8577
+ die2(`--ship aborted: archive step failed after folding task telemetry: ${message}
8578
+
8579
+ ` + formatPostFoldFailureHint(foldedDocs, "uncommitted", baseBranch));
8580
+ }
8581
+ let archiveCommit;
8582
+ try {
8583
+ archiveCommit = commitArchiveChanges(taskIds, baseBranch);
8584
+ } catch (error) {
8585
+ if (foldedDocs.length === 0) throw error;
8586
+ const message = error instanceof Error ? error.message : String(error);
8587
+ return die2(`--ship aborted: archive commit was created locally but the push failed: ${message}
8588
+
8589
+ ` + formatPostFoldFailureHint(foldedDocs, "unpushed", baseBranch));
8590
+ }
8387
8591
  if (archiveCommit.stderr) {
8388
- die2(`--ship aborted: failed to commit archive changes: ${archiveCommit.stderr}`);
8592
+ const hint = foldedDocs.length > 0 ? `
8593
+
8594
+ ${formatPostFoldFailureHint(foldedDocs, "uncommitted", baseBranch)}` : "";
8595
+ die2(`--ship aborted: failed to commit archive changes: ${archiveCommit.stderr}${hint}`);
8389
8596
  }
8390
8597
  for (const branch of localBranchesToDelete) {
8391
8598
  const result = gitSafe("branch", "-D", branch);
@@ -8441,7 +8648,7 @@ function rerouteFromHumanReview(taskIds) {
8441
8648
  if (!result.amended) {
8442
8649
  amendmentFailures.push({
8443
8650
  taskId,
8444
- specPath: path21.join(taskDirFor2(taskId), "spec.md"),
8651
+ specPath: path22.join(taskDirFor2(taskId), "spec.md"),
8445
8652
  requiredRound,
8446
8653
  expectedHeading: requiredRound === 1 ? "## Amendment" : `## Amendment Round ${requiredRound}`,
8447
8654
  reason: result.reason
@@ -8497,7 +8704,7 @@ function rerouteFromHumanReview(taskIds) {
8497
8704
  }
8498
8705
  const rescaffold = rescaffoldReview(taskDirFor2(taskId), scaffoldIdentity);
8499
8706
  if (rescaffold.outcome === "written") {
8500
- info(`Re-scaffolded tasks/${taskId}/review.md from ${path21.relative(process.cwd(), rescaffold.source)}.`);
8707
+ info(`Re-scaffolded tasks/${taskId}/review.md from ${path22.relative(process.cwd(), rescaffold.source)}.`);
8501
8708
  } else if (rescaffold.outcome === "no-template") {
8502
8709
  warn2(
8503
8710
  `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 +8838,7 @@ async function runPhase(phase, state) {
8631
8838
  if (phase === "qa") {
8632
8839
  const activeCwd = getActiveCwd(taskIds);
8633
8840
  const qaTemplatePath = state.isBundle ? null : findPullRequestTemplate(activeCwd) ?? findPullRequestTemplate(REPO_ROOT2);
8634
- const resolvedPrTemplate = qaTemplatePath ? fs22.readFileSync(qaTemplatePath, "utf8") : null;
8841
+ const resolvedPrTemplate = qaTemplatePath ? fs23.readFileSync(qaTemplatePath, "utf8") : null;
8635
8842
  return runQaPhase(state, cliArgs.interactive, resolvedPrTemplate);
8636
8843
  }
8637
8844
  if (phase === "human_review") {
@@ -8645,7 +8852,7 @@ async function runPhase(phase, state) {
8645
8852
  }
8646
8853
  const branch = [...branches][0];
8647
8854
  const cwd = getActiveCwd(taskIds2);
8648
- const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path21.join(cwd, "tasks");
8855
+ const tasksRootForGate = process.env.CANON_TASKS_DIR_OVERRIDE ?? path22.join(cwd, "tasks");
8649
8856
  for (const taskId of taskIds2) {
8650
8857
  const gateResult = checkPhaseGate(taskId, "human_review", void 0, tasksRootForGate);
8651
8858
  if (!gateResult.ok) {
@@ -8712,9 +8919,9 @@ async function runPhase(phase, state) {
8712
8919
  }
8713
8920
  var extractCheckedVerdict2 = extractCheckedVerdict;
8714
8921
  function readArtifact(taskId, name) {
8715
- const p = path21.join(taskDirFor2(taskId), name);
8922
+ const p = path22.join(taskDirFor2(taskId), name);
8716
8923
  try {
8717
- return fs22.readFileSync(p, "utf8");
8924
+ return fs23.readFileSync(p, "utf8");
8718
8925
  } catch {
8719
8926
  return null;
8720
8927
  }
@@ -8730,15 +8937,15 @@ function checkImplementEvidence(taskId) {
8730
8937
  return { advanced: false, note: `handoff.md Changes table has malformed row(s): ${sample}${tail}` };
8731
8938
  }
8732
8939
  const issues = validateHandoffAgainstSpec(
8733
- path21.join(taskDirFor2(taskId), "spec.md"),
8734
- path21.join(taskDirFor2(taskId), "handoff.md")
8940
+ path22.join(taskDirFor2(taskId), "spec.md"),
8941
+ path22.join(taskDirFor2(taskId), "handoff.md")
8735
8942
  );
8736
8943
  if (issues.length > 0) return { advanced: false, note: `handoff.md validation failed: ${issues.join("; ")}` };
8737
8944
  const checkRoots = [REPO_ROOT2];
8738
8945
  const sForEvidence = readStatus(taskId);
8739
8946
  if (sForEvidence.worktree === true) {
8740
8947
  const wt = worktreePath(taskId);
8741
- if (fs22.existsSync(wt)) checkRoots.push(wt);
8948
+ if (fs23.existsSync(wt)) checkRoots.push(wt);
8742
8949
  }
8743
8950
  const ignoreCwd = checkRoots[checkRoots.length - 1];
8744
8951
  const gitIgnored = filterGitIgnoredPaths(files, ignoreCwd);
@@ -8750,7 +8957,7 @@ function checkImplementEvidence(taskId) {
8750
8957
  };
8751
8958
  }
8752
8959
  const existingFiles = verifiableFiles.filter(
8753
- (f) => checkRoots.some((root) => fs22.existsSync(path21.join(root, f)))
8960
+ (f) => checkRoots.some((root) => fs23.existsSync(path22.join(root, f)))
8754
8961
  );
8755
8962
  if (existingFiles.length === 0) {
8756
8963
  const evidenceCwd = checkRoots[checkRoots.length - 1];
@@ -8835,7 +9042,7 @@ function tryEvidenceAdvance(taskId, phase) {
8835
9042
  return { advanced: true, note: "spec.md is populated" };
8836
9043
  }
8837
9044
  case "qa": {
8838
- const donePath = path21.join(taskDirFor(taskId), "done.md");
9045
+ const donePath = path22.join(taskDirFor(taskId), "done.md");
8839
9046
  if (isDoneMdTemplate(donePath)) return { advanced: false, note: "done.md is still the template" };
8840
9047
  taskPhase(taskId, "qa", "done");
8841
9048
  return { advanced: true, note: "done.md is populated" };
@@ -9186,9 +9393,9 @@ function checkDeps(taskIds, skipAgentDeps = false) {
9186
9393
  }
9187
9394
  for (const taskId of taskIds) {
9188
9395
  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)) {
9396
+ const repoRootStatusFile = path22.join(REPO_ROOT2, "tasks", taskId, "status.json");
9397
+ const statusFile = cliArgs.ship && fs23.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(taskId);
9398
+ if (!fs23.existsSync(statusFile)) {
9192
9399
  die(`No status.json at tasks/${taskId}/status.json \u2014 run canon task new ${taskId} first`);
9193
9400
  }
9194
9401
  }
@@ -9233,8 +9440,8 @@ async function main() {
9233
9440
  const earlyHeartbeatTaskIds = cliArgs.taskIds;
9234
9441
  let heartbeatStarted = false;
9235
9442
  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));
9443
+ const repoRootStatusFile = path22.join(REPO_ROOT2, "tasks", id, "status.json");
9444
+ return path22.dirname(cliArgs.ship && fs23.existsSync(repoRootStatusFile) ? repoRootStatusFile : statusFileFor(id));
9238
9445
  };
9239
9446
  if (!cliArgs.ship && !cliArgs.dryRun) {
9240
9447
  guardConcurrentRun(cliArgs.taskIds, earlyHeartbeatResolver);
@@ -9270,7 +9477,7 @@ async function main() {
9270
9477
  }
9271
9478
  refreshCanonSnapshotsAtPaths(taskIds.map(statusFileFor));
9272
9479
  const initialState = buildPipelineState(taskIds);
9273
- const heartbeatDirResolver = (id) => path21.dirname(statusFileFor(id));
9480
+ const heartbeatDirResolver = (id) => path22.dirname(statusFileFor(id));
9274
9481
  if (!isSynchronousMode(cliArgs) && shouldAutoDetach()) {
9275
9482
  detachAndExit({
9276
9483
  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.0",
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