@cleocode/core 2026.10.25 → 2026.10.26-rc.ci.697

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.
Files changed (198) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/dispatch/contracts/output-contracts.d.ts +13 -0
  3. package/dist/dispatch/contracts/output-contracts.d.ts.map +1 -1
  4. package/dist/dispatch/contracts/output-contracts.js +29 -0
  5. package/dist/dispatch/contracts/output-contracts.js.map +1 -1
  6. package/dist/docs/docs-ops.d.ts +8 -0
  7. package/dist/docs/docs-ops.d.ts.map +1 -1
  8. package/dist/docs/docs-ops.js +24 -1
  9. package/dist/docs/docs-ops.js.map +1 -1
  10. package/dist/docs/docs-update.d.ts +8 -5
  11. package/dist/docs/docs-update.d.ts.map +1 -1
  12. package/dist/docs/docs-update.js +16 -5
  13. package/dist/docs/docs-update.js.map +1 -1
  14. package/dist/gateway-client/generated/types.gen.d.ts +27 -0
  15. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  16. package/dist/hooks/handlers/watchdog-hooks.d.ts.map +1 -1
  17. package/dist/hooks/handlers/watchdog-hooks.js +5 -0
  18. package/dist/hooks/handlers/watchdog-hooks.js.map +1 -1
  19. package/dist/internal.d.ts +3 -2
  20. package/dist/internal.d.ts.map +1 -1
  21. package/dist/internal.js +5 -2
  22. package/dist/internal.js.map +1 -1
  23. package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
  24. package/dist/lifecycle/verification/satisfies-validator.js +22 -10
  25. package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
  26. package/dist/memory/brain-writer-thread.d.ts +30 -0
  27. package/dist/memory/brain-writer-thread.d.ts.map +1 -1
  28. package/dist/memory/brain-writer-thread.js +46 -1
  29. package/dist/memory/brain-writer-thread.js.map +1 -1
  30. package/dist/memory/brain-writer-worker.js +13 -2
  31. package/dist/memory/brain-writer-worker.js.map +1 -1
  32. package/dist/memory/embedding-backfill.d.ts +12 -0
  33. package/dist/memory/embedding-backfill.d.ts.map +1 -1
  34. package/dist/memory/embedding-backfill.js +51 -31
  35. package/dist/memory/embedding-backfill.js.map +1 -1
  36. package/dist/orchestrate/include-deps.d.ts +58 -0
  37. package/dist/orchestrate/include-deps.d.ts.map +1 -0
  38. package/dist/orchestrate/include-deps.js +138 -0
  39. package/dist/orchestrate/include-deps.js.map +1 -0
  40. package/dist/orchestrate/spawn-ops.d.ts +14 -33
  41. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  42. package/dist/orchestrate/spawn-ops.js +49 -61
  43. package/dist/orchestrate/spawn-ops.js.map +1 -1
  44. package/dist/orchestrate/worktree-complete.d.ts +15 -1
  45. package/dist/orchestrate/worktree-complete.d.ts.map +1 -1
  46. package/dist/orchestrate/worktree-complete.js +36 -3
  47. package/dist/orchestrate/worktree-complete.js.map +1 -1
  48. package/dist/release/ci-evidence.d.ts +142 -4
  49. package/dist/release/ci-evidence.d.ts.map +1 -1
  50. package/dist/release/ci-evidence.js +298 -48
  51. package/dist/release/ci-evidence.js.map +1 -1
  52. package/dist/release/open.d.ts +25 -1
  53. package/dist/release/open.d.ts.map +1 -1
  54. package/dist/release/open.js +63 -11
  55. package/dist/release/open.js.map +1 -1
  56. package/dist/resources/admission-ledger.d.ts +83 -11
  57. package/dist/resources/admission-ledger.d.ts.map +1 -1
  58. package/dist/resources/admission-ledger.js +138 -39
  59. package/dist/resources/admission-ledger.js.map +1 -1
  60. package/dist/resources/governor.js +1 -1
  61. package/dist/resources/governor.js.map +1 -1
  62. package/dist/resources/hook-footprint.d.ts +24 -0
  63. package/dist/resources/hook-footprint.d.ts.map +1 -1
  64. package/dist/resources/hook-footprint.js +38 -1
  65. package/dist/resources/hook-footprint.js.map +1 -1
  66. package/dist/resources/run-class.d.ts +38 -5
  67. package/dist/resources/run-class.d.ts.map +1 -1
  68. package/dist/resources/run-class.js +60 -11
  69. package/dist/resources/run-class.js.map +1 -1
  70. package/dist/resources/run-governed.d.ts +13 -3
  71. package/dist/resources/run-governed.d.ts.map +1 -1
  72. package/dist/resources/run-governed.js +42 -9
  73. package/dist/resources/run-governed.js.map +1 -1
  74. package/dist/resources/slot-holder.d.ts +3 -3
  75. package/dist/resources/slot-holder.js +3 -3
  76. package/dist/resources/tool-groups.d.ts +11 -7
  77. package/dist/resources/tool-groups.d.ts.map +1 -1
  78. package/dist/resources/tool-groups.js +24 -13
  79. package/dist/resources/tool-groups.js.map +1 -1
  80. package/dist/spawn/branch-integration.d.ts +281 -0
  81. package/dist/spawn/branch-integration.d.ts.map +1 -0
  82. package/dist/spawn/branch-integration.js +715 -0
  83. package/dist/spawn/branch-integration.js.map +1 -0
  84. package/dist/spawn/branch-lock.d.ts +32 -270
  85. package/dist/spawn/branch-lock.d.ts.map +1 -1
  86. package/dist/spawn/branch-lock.js +59 -770
  87. package/dist/spawn/branch-lock.js.map +1 -1
  88. package/dist/spawn/index.d.ts +3 -1
  89. package/dist/spawn/index.d.ts.map +1 -1
  90. package/dist/spawn/index.js +5 -1
  91. package/dist/spawn/index.js.map +1 -1
  92. package/dist/store/exodus/recovery.d.ts.map +1 -1
  93. package/dist/store/exodus/recovery.js +9 -4
  94. package/dist/store/exodus/recovery.js.map +1 -1
  95. package/dist/system/health.d.ts.map +1 -1
  96. package/dist/system/health.js +3 -1
  97. package/dist/system/health.js.map +1 -1
  98. package/dist/tasks/ac-coverage-gate.d.ts +18 -2
  99. package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
  100. package/dist/tasks/ac-coverage-gate.js +40 -6
  101. package/dist/tasks/ac-coverage-gate.js.map +1 -1
  102. package/dist/tasks/ac-table.d.ts.map +1 -1
  103. package/dist/tasks/ac-table.js +3 -10
  104. package/dist/tasks/ac-table.js.map +1 -1
  105. package/dist/tasks/affected-packages.d.ts.map +1 -1
  106. package/dist/tasks/affected-packages.js +54 -10
  107. package/dist/tasks/affected-packages.js.map +1 -1
  108. package/dist/tasks/affected-scope.d.ts +10 -1
  109. package/dist/tasks/affected-scope.d.ts.map +1 -1
  110. package/dist/tasks/affected-scope.js +13 -3
  111. package/dist/tasks/affected-scope.js.map +1 -1
  112. package/dist/tasks/change-set.d.ts +9 -6
  113. package/dist/tasks/change-set.d.ts.map +1 -1
  114. package/dist/tasks/change-set.js +75 -97
  115. package/dist/tasks/change-set.js.map +1 -1
  116. package/dist/tasks/command-line.d.ts.map +1 -1
  117. package/dist/tasks/command-line.js +6 -2
  118. package/dist/tasks/command-line.js.map +1 -1
  119. package/dist/tasks/complete.d.ts.map +1 -1
  120. package/dist/tasks/complete.js +7 -2
  121. package/dist/tasks/complete.js.map +1 -1
  122. package/dist/tasks/done-plan.d.ts.map +1 -1
  123. package/dist/tasks/done-plan.js +111 -29
  124. package/dist/tasks/done-plan.js.map +1 -1
  125. package/dist/tasks/done-record.d.ts +14 -0
  126. package/dist/tasks/done-record.d.ts.map +1 -1
  127. package/dist/tasks/done-record.js +17 -10
  128. package/dist/tasks/done-record.js.map +1 -1
  129. package/dist/tasks/evidence-tree.d.ts +33 -0
  130. package/dist/tasks/evidence-tree.d.ts.map +1 -0
  131. package/dist/tasks/evidence-tree.js +132 -0
  132. package/dist/tasks/evidence-tree.js.map +1 -0
  133. package/dist/tasks/evidence.d.ts +42 -6
  134. package/dist/tasks/evidence.d.ts.map +1 -1
  135. package/dist/tasks/evidence.js +175 -79
  136. package/dist/tasks/evidence.js.map +1 -1
  137. package/dist/tasks/gate-preview.d.ts.map +1 -1
  138. package/dist/tasks/gate-preview.js +3 -2
  139. package/dist/tasks/gate-preview.js.map +1 -1
  140. package/dist/tasks/gate-runner.d.ts +11 -3
  141. package/dist/tasks/gate-runner.d.ts.map +1 -1
  142. package/dist/tasks/gate-runner.js +156 -37
  143. package/dist/tasks/gate-runner.js.map +1 -1
  144. package/dist/tasks/gate-staleness.d.ts +58 -0
  145. package/dist/tasks/gate-staleness.d.ts.map +1 -0
  146. package/dist/tasks/gate-staleness.js +78 -0
  147. package/dist/tasks/gate-staleness.js.map +1 -0
  148. package/dist/tasks/heavy-tool-env.d.ts +26 -1
  149. package/dist/tasks/heavy-tool-env.d.ts.map +1 -1
  150. package/dist/tasks/heavy-tool-env.js +92 -5
  151. package/dist/tasks/heavy-tool-env.js.map +1 -1
  152. package/dist/tasks/heavy-tool-limit.d.ts +24 -0
  153. package/dist/tasks/heavy-tool-limit.d.ts.map +1 -1
  154. package/dist/tasks/heavy-tool-limit.js +44 -1
  155. package/dist/tasks/heavy-tool-limit.js.map +1 -1
  156. package/dist/tasks/show.d.ts.map +1 -1
  157. package/dist/tasks/show.js +43 -0
  158. package/dist/tasks/show.js.map +1 -1
  159. package/dist/tasks/test-run-binding.d.ts +48 -3
  160. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  161. package/dist/tasks/test-run-binding.js +111 -3
  162. package/dist/tasks/test-run-binding.js.map +1 -1
  163. package/dist/tasks/tool-cache.d.ts +58 -7
  164. package/dist/tasks/tool-cache.d.ts.map +1 -1
  165. package/dist/tasks/tool-cache.js +216 -16
  166. package/dist/tasks/tool-cache.js.map +1 -1
  167. package/dist/tasks/tool-resolver.d.ts +13 -0
  168. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  169. package/dist/tasks/tool-resolver.js +34 -0
  170. package/dist/tasks/tool-resolver.js.map +1 -1
  171. package/dist/tasks/tool-runner-guard.d.ts +6 -0
  172. package/dist/tasks/tool-runner-guard.d.ts.map +1 -1
  173. package/dist/tasks/tool-runner-guard.js.map +1 -1
  174. package/dist/tasks/tool-semaphore.js +1 -1
  175. package/dist/tasks/tool-semaphore.js.map +1 -1
  176. package/dist/tasks/verification-policy.d.ts +26 -3
  177. package/dist/tasks/verification-policy.d.ts.map +1 -1
  178. package/dist/tasks/verification-policy.js +76 -11
  179. package/dist/tasks/verification-policy.js.map +1 -1
  180. package/dist/validation/doctor/checks.d.ts +12 -0
  181. package/dist/validation/doctor/checks.d.ts.map +1 -1
  182. package/dist/validation/doctor/checks.js +33 -0
  183. package/dist/validation/doctor/checks.js.map +1 -1
  184. package/dist/validation/engine-ops.d.ts +6 -0
  185. package/dist/validation/engine-ops.d.ts.map +1 -1
  186. package/dist/validation/engine-ops.js +51 -1
  187. package/dist/validation/engine-ops.js.map +1 -1
  188. package/dist/worktree/force-unlock.js +2 -2
  189. package/dist/worktree/force-unlock.js.map +1 -1
  190. package/dist/worktree/list.d.ts +7 -7
  191. package/dist/worktree/list.d.ts.map +1 -1
  192. package/dist/worktree/list.js +11 -56
  193. package/dist/worktree/list.js.map +1 -1
  194. package/dist/worktree/prune.d.ts +2 -2
  195. package/dist/worktree/prune.d.ts.map +1 -1
  196. package/dist/worktree/prune.js +4 -4
  197. package/dist/worktree/prune.js.map +1 -1
  198. package/package.json +13 -13
@@ -10,7 +10,8 @@
10
10
  * - {@link resolveToolCommand} maps `tool:<name>` to a runnable command
11
11
  * using `.cleo/project-context.json` and per-`primaryType` fallbacks.
12
12
  * - {@link runToolCached} memoises results per
13
- * `(canonical, cmd, args, treeHash, envFingerprint)` and serialises
13
+ * `(canonical, cmd, args, treeHash, envFingerprint, resourceEnv)` (the
14
+ * heap and worker limits the run gets, T12989) and serialises
14
15
  * concurrent identical runs via a cross-process lock,
15
16
  * preventing the resource thrash observed when multiple `cleo verify`
16
17
  * invocations spawned full toolchains in parallel.
@@ -36,11 +37,11 @@ import { HEAVY_HEAP_ENV, HEAVY_WORKERS_ENV, isHeavyTool } from './heavy-tool-env
36
37
  import { DISABLE_ENV, describeMemoryLimit } from './heavy-tool-limit.js';
37
38
  import { qaRunUnionGapReason, typecheckCoverageGap } from './qa-run-binding.js';
38
39
  import { computeCommitRevalidationKey, readCommitRevalidationEntry, writeCommitRevalidationEntry, } from './revalidation-cache.js';
39
- import { bindTestRunReport, configSchemaCompositionGap, coveredTestFiles, TEST_RUN_MAX_RECORDED_FILES, } from './test-run-binding.js';
40
+ import { bindTestRunReport, configSchemaCompositionGap, coveredTestFiles, playwrightTestRun, TEST_RUN_MAX_RECORDED_FILES, } from './test-run-binding.js';
40
41
  import { captureHead, captureTree, resolveSpawnTimeoutMs, runToolCached, } from './tool-cache.js';
41
42
  import { CANONICAL_TOOLS, canonicalToolName, listValidToolNames, resolveToolCommand, } from './tool-resolver.js';
42
43
  import { detectStaticVacuity, probeTscFileCount, VACUOUS_TOOL_FIX } from './tool-vacuity.js';
43
- import { classifyEvidenceTask, isDocumentArtifact, isDocumentEvidencePath, } from './verification-policy.js';
44
+ import { classifyEvidenceTask, declaredEvidenceKind, isDocumentArtifact, isDocumentEvidencePath, } from './verification-policy.js';
44
45
  /**
45
46
  * Valid tool names recognised by the `tool:<name>` evidence atom.
46
47
  *
@@ -229,7 +230,7 @@ siblingCommitSha, context) {
229
230
  case 'commit':
230
231
  return validateCommit(parsed.sha, roots, taskId);
231
232
  case 'files': {
232
- const result = await validateFiles(parsed.paths, roots, taskId, anchorFor, Boolean(artifactPrs ?? context?.artifactCommitSha));
233
+ const result = await validateFiles(parsed.paths, roots, taskId, anchorFor, Boolean(artifactPrs ?? context?.artifactCommitSha), (path) => artifactPrs?.find((pr) => pr.deletedPaths?.some((deleted) => normalizeEvidencePath(deleted) === normalizeEvidencePath(path))));
233
234
  if (!result.ok || result.atom.kind !== 'files' || (artifactPrs?.length ?? 0) < 2)
234
235
  return result;
235
236
  return {
@@ -238,7 +239,7 @@ siblingCommitSha, context) {
238
239
  };
239
240
  }
240
241
  case 'test-run':
241
- return validateTestRun(parsed.path, roots, taskId ?? context?.task.id);
242
+ return validateTestRun(parsed.path, roots, taskId ?? context?.task.id, context?.testRunPaths ?? []);
242
243
  case 'qa-run':
243
244
  return validateQaRun(parsed.path, roots, taskId ?? context?.task.id);
244
245
  case 'tool':
@@ -875,7 +876,13 @@ async function commitFilesOrNull(sha, projectRoot) {
875
876
  export function diffIntersectsAc(diffFiles, acFiles) {
876
877
  const norm = (s) => s.replace(/^\.\//, '').replace(/\/+$/, '').toLowerCase();
877
878
  const acSet = new Set(acFiles.map(norm));
878
- const acPrefixes = acFiles.map(norm).filter((p) => !p.includes('.') || p.endsWith('/'));
879
+ // A directory entry: declared with a trailing slash, or whose last segment
880
+ // has no extension. Judged on the raw entry and its last segment (T14073):
881
+ // the old test ran on the normalised path, whose trailing slash is already
882
+ // stripped, so any dot earlier in it (`.cleo/rcasd/T1/`) hid the directory.
883
+ const acPrefixes = acFiles
884
+ .filter((raw) => /\/\s*$/.test(raw) || !(norm(raw).split('/').pop() ?? '').includes('.'))
885
+ .map(norm);
879
886
  for (const f of diffFiles) {
880
887
  const nf = norm(f);
881
888
  if (acSet.has(nf))
@@ -1161,6 +1168,8 @@ async function gitShowFileContentAtCommit(relPath, sha, projectRoot) {
1161
1168
  * enables the commit-tree-first check (T12107).
1162
1169
  * @param requireCommitArtifact - Refuse when the anchor tree lacks the path
1163
1170
  * (a PR merge anchor: checkout bytes cannot substitute).
1171
+ * @param deletedBy - The PR of the write that deleted a path, if any: such a
1172
+ * path is refused by name rather than as an unfetched merge (T14030).
1164
1173
  * @returns Validated atom on success, error on failure.
1165
1174
  *
1166
1175
  * @internal
@@ -1168,7 +1177,7 @@ async function gitShowFileContentAtCommit(relPath, sha, projectRoot) {
1168
1177
  * @task T11959
1169
1178
  * @task T12107
1170
1179
  */
1171
- async function validateFiles(paths, roots, taskId, commitShaFor, requireCommitArtifact = false) {
1180
+ async function validateFiles(paths, roots, taskId, commitShaFor, requireCommitArtifact = false, deletedBy = () => undefined) {
1172
1181
  const { storeRoot: projectRoot, executionRoot } = roots;
1173
1182
  if (paths.length === 0) {
1174
1183
  return {
@@ -1195,6 +1204,16 @@ async function validateFiles(paths, roots, taskId, commitShaFor, requireCommitAr
1195
1204
  content = await gitShowFileContentAtCommit(p, commitSha, executionRoot);
1196
1205
  }
1197
1206
  if (requireCommitArtifact && content === null) {
1207
+ const deleter = deletedBy(p);
1208
+ if (deleter !== undefined)
1209
+ // @sync-invariant none:input-shape a files: path the PR deleted is refused before anything is written
1210
+ return {
1211
+ ok: false,
1212
+ codeName: 'E_EVIDENCE_INVALID',
1213
+ reason: `${p} was deleted by ${deleter.prNumber !== undefined ? `PR #${deleter.prNumber}` : 'the PR'}, ` +
1214
+ `so its merge ${deleter.mergeCommitSha} holds no bytes to inspect. Leave it out of files: ` +
1215
+ '(name the deletion in note: if it matters); files: lists artifacts the PR changed and kept.',
1216
+ };
1198
1217
  return {
1199
1218
  ok: false,
1200
1219
  codeName: 'E_EVIDENCE_INSUFFICIENT',
@@ -1389,12 +1408,16 @@ async function runAffectedTests(tool, run, roots, taskId) {
1389
1408
  executionRoot,
1390
1409
  semaphoreOptions: { scope: 'affected', ...(taskId !== undefined ? { task: taskId } : {}) },
1391
1410
  });
1411
+ const label = `tool:${tool} (affected: ${[run.command.cmd, ...run.command.args].join(' ')})`;
1412
+ // T13013: an OOM or signal kill is reported as a kill, never as failing tests.
1413
+ const killed = result.timedOut ? null : killedValidation(label, run.command.canonical, result);
1414
+ if (killed !== null)
1415
+ return killed;
1392
1416
  if (result.exitCode !== 0) {
1393
1417
  return {
1394
1418
  ok: false,
1395
1419
  codeName: result.timedOut ? 'E_EVIDENCE_TOOL_TIMEOUT' : 'E_EVIDENCE_TOOL_FAILED',
1396
- reason: `tool:${tool} (affected: ${[run.command.cmd, ...run.command.args].join(' ')}) exited ${result.exitCode}: ${(result.stderrTail || result.stdoutTail).trim().slice(-300)}` +
1397
- (result.signal !== null || result.resourceKill !== null ? resourcePlanNote(result) : ''),
1420
+ reason: `${label} exited ${result.exitCode}: ${(result.stderrTail || result.stdoutTail).trim().slice(-300)}`,
1398
1421
  };
1399
1422
  }
1400
1423
  return {
@@ -1414,7 +1437,7 @@ async function runAffectedTests(tool, run, roots, taskId) {
1414
1437
  ...(run.testFiles ? { affectedTestFiles: run.testFiles } : {}),
1415
1438
  ...(run.configPaths ? { configPaths: run.configPaths } : {}),
1416
1439
  ...(run.untested.length > 0 ? { untestedPackages: run.untested } : {}),
1417
- ...toolRunAtomFields(result),
1440
+ ...(await toolRunAtomFields(result)),
1418
1441
  },
1419
1442
  };
1420
1443
  }
@@ -1543,14 +1566,13 @@ hints = {}) {
1543
1566
  }
1544
1567
  return projectRoot;
1545
1568
  }
1546
- async function validateTestRun(path, roots, taskId) {
1569
+ /**
1570
+ * Resolve, read and parse a `test-run:` report. gh#1226: a relative path
1571
+ * names a report the caller just wrote, in the caller's tree, so the
1572
+ * execution root is tried before the store root.
1573
+ */
1574
+ async function readTestRunReport(path, roots) {
1547
1575
  const { storeRoot: projectRoot, executionRoot } = roots;
1548
- // gh#1226: a relative test-run path names a report the caller just wrote,
1549
- // in the caller's tree. Resolving it against the shared store root made a
1550
- // report written in a worktree report "file does not exist". Try the
1551
- // execution root first, then fall back to the store root so an absolute or
1552
- // store-relative path keeps working. gh#1365: the root arrives as a
1553
- // parameter now; it is NOT re-resolved here.
1554
1576
  let abs;
1555
1577
  if (isAbsolute(path)) {
1556
1578
  abs = path;
@@ -1578,10 +1600,8 @@ async function validateTestRun(path, roots, taskId) {
1578
1600
  codeName: 'E_EVIDENCE_INVALID',
1579
1601
  };
1580
1602
  }
1581
- const sha256 = createHash('sha256').update(content).digest('hex');
1582
- let parsed;
1583
1603
  try {
1584
- parsed = JSON.parse(content.toString('utf-8'));
1604
+ return { ok: true, abs, content, parsed: JSON.parse(content.toString('utf-8')) };
1585
1605
  }
1586
1606
  catch (err) {
1587
1607
  return {
@@ -1590,6 +1610,22 @@ async function validateTestRun(path, roots, taskId) {
1590
1610
  codeName: 'E_EVIDENCE_INVALID',
1591
1611
  };
1592
1612
  }
1613
+ }
1614
+ /**
1615
+ * Validate a `test-run:` report: its counts, then bound to the change.
1616
+ * `siblings` are every `test-run:` path of the same evidence write (T14031):
1617
+ * the change's coverage is judged over the test files all of them passed, so
1618
+ * a vitest report and a Playwright report together cover unit and e2e files.
1619
+ * Each sibling is still validated on its own by its own atom.
1620
+ */
1621
+ async function validateTestRun(path, roots, taskId, siblings = []) {
1622
+ const { storeRoot: projectRoot, executionRoot } = roots;
1623
+ const read = await readTestRunReport(path, roots);
1624
+ if (!read.ok)
1625
+ return read;
1626
+ const { abs, content } = read;
1627
+ let parsed = read.parsed;
1628
+ const sha256 = createHash('sha256').update(content).digest('hex');
1593
1629
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1594
1630
  return {
1595
1631
  ok: false,
@@ -1597,6 +1633,12 @@ async function validateTestRun(path, roots, taskId) {
1597
1633
  codeName: 'E_EVIDENCE_INVALID',
1598
1634
  };
1599
1635
  }
1636
+ // T14031: a Playwright JSON report is read in the vitest shape.
1637
+ const playwright = playwrightTestRun(parsed);
1638
+ if (playwright !== null && 'refusal' in playwright)
1639
+ return { ok: false, reason: playwright.refusal, codeName: playwright.codeName };
1640
+ if (playwright !== null)
1641
+ parsed = playwright;
1600
1642
  if (present(parsed.testResults) &&
1601
1643
  (!Array.isArray(parsed.testResults) ||
1602
1644
  parsed.testResults.some((tr) => tr === null || typeof tr !== 'object' || Array.isArray(tr)))) {
@@ -1614,7 +1656,7 @@ async function validateTestRun(path, roots, taskId) {
1614
1656
  return {
1615
1657
  ok: false,
1616
1658
  reason: `test-run report has no test counts. Expected one of: ${TEST_RUN_SHAPE_HINT}; ` +
1617
- "e.g. vitest's --reporter=json --outputFile, or " +
1659
+ "e.g. vitest's --reporter=json --outputFile, Playwright's --reporter=json, or " +
1618
1660
  '{"total":43,"passed":42,"failed":0,"skipped":1}',
1619
1661
  codeName: 'E_EVIDENCE_INVALID',
1620
1662
  };
@@ -1694,6 +1736,19 @@ async function validateTestRun(path, roots, taskId) {
1694
1736
  if (broken)
1695
1737
  return { ok: false, reason: broken, codeName: E_EVIDENCE_GIT_ROOT };
1696
1738
  const testFiles = coveredTestFiles(parsed, bindRoot);
1739
+ // T14031: the other reports of this write cover the change with this one.
1740
+ const siblingFiles = [];
1741
+ for (const sibling of siblings) {
1742
+ if (sibling === path)
1743
+ continue;
1744
+ const other = await readTestRunReport(sibling, roots);
1745
+ if (!other.ok || other.parsed === null || typeof other.parsed !== 'object')
1746
+ continue;
1747
+ const adapted = playwrightTestRun(other.parsed);
1748
+ if (adapted !== null && 'refusal' in adapted)
1749
+ continue;
1750
+ siblingFiles.push(...coveredTestFiles(adapted ?? other.parsed, bindRoot));
1751
+ }
1697
1752
  // The tree hash is the tool cache's (T12958), so a bound test-run and a
1698
1753
  // cached tool run of the same content agree.
1699
1754
  const capture = await captureTree(bindRoot, await typedGateInputs(projectRoot, taskId, bindRoot));
@@ -1709,7 +1764,7 @@ async function validateTestRun(path, roots, taskId) {
1709
1764
  let typecheckDirs = [];
1710
1765
  let untrackedSetAside = [];
1711
1766
  if (identity) {
1712
- const binding = bindTestRunReport(parsed, abs, bindRoot, testFiles);
1767
+ const binding = bindTestRunReport(parsed, abs, bindRoot, siblingFiles.length > 0 ? [...new Set([...testFiles, ...siblingFiles])].sort() : testFiles);
1713
1768
  if (!binding.ok)
1714
1769
  return { ok: false, reason: binding.reason, codeName: binding.codeName };
1715
1770
  untestedPackages = binding.untestedPackages;
@@ -1995,6 +2050,12 @@ async function validateConfigSchema(roots, context, siblingCommitSha) {
1995
2050
  const schema = JSON.parse(await readFile(schemaPath, 'utf-8'));
1996
2051
  for (const error of checkSchema(data, schema))
1997
2052
  failures.push(`${path}: ${error}`);
2053
+ // T14039: a tool command that cannot run without a shell is a config error.
2054
+ if (schemaName === 'project-context.schema.json') {
2055
+ const { projectContextCommandProblems } = await import('./tool-resolver.js');
2056
+ for (const problem of projectContextCommandProblems(data))
2057
+ failures.push(`${path}: ${problem}`);
2058
+ }
1998
2059
  }
1999
2060
  if (failures.length > 0)
2000
2061
  return {
@@ -2129,7 +2190,8 @@ async function validateTool(tool, roots, context, siblingCommitSha) {
2129
2190
  return {
2130
2191
  ok: false,
2131
2192
  reason: resolution.reason,
2132
- codeName: resolution.codeName === 'E_TOOL_UNKNOWN'
2193
+ // T14039: a declared command with shell syntax is a config error, not a missing tool.
2194
+ codeName: resolution.codeName === 'E_TOOL_UNKNOWN' || resolution.codeName === 'E_TOOL_COMMAND_INVALID'
2133
2195
  ? 'E_EVIDENCE_INVALID'
2134
2196
  : 'E_EVIDENCE_TOOL_UNAVAILABLE',
2135
2197
  };
@@ -2189,49 +2251,10 @@ async function validateTool(tool, roots, context, siblingCommitSha) {
2189
2251
  codeName: 'E_EVIDENCE_TOOL_BUSY',
2190
2252
  };
2191
2253
  }
2192
- // gh#1381: a tool KILLED after it started is a different fact from a tool
2193
- // that never started, and until the signal was bound in `spawnCmd` the two
2194
- // arrived here as the same `exitCode: null`. The message below used to say
2195
- // "binary missing or spawn error" for both — so an OOM-killed 41-minute test
2196
- // suite sent three separate operators hunting a binary that was present, and
2197
- // the one diagnostic that would have helped (you exceeded the memory
2198
- // ceiling) was the one fact discarded.
2199
- if (result.signal !== null) {
2200
- const limit = describeMemoryLimit(resolution.command.canonical);
2201
- return {
2202
- ok: false,
2203
- reason: `Tool "${tool}" → ${resolution.command.cmd} ${resolution.command.args.join(' ')} ` +
2204
- `RAN for ${Math.round(result.durationMs / 1000)}s in ${result.executionRoot} and was ` +
2205
- `KILLED by ${result.signal}. The binary is present and the command started — ` +
2206
- `this is not a resolution problem.${limit}${resourcePlanNote(result)} ` +
2207
- `Nothing was cached, so a retry re-runs the tool from scratch and will be killed ` +
2208
- `identically unless the cause is addressed.` +
2209
- (result.stdoutTail || result.stderrTail
2210
- ? ` Last output before the kill: ${tailString(`${result.stdoutTail}\n${result.stderrTail}`, 512)}`
2211
- : ''),
2212
- codeName: 'E_EVIDENCE_TOOL_KILLED',
2213
- };
2214
- }
2215
- // T12989: killed for resources with no signal reaching CLEO — a wrapper
2216
- // (`sh`, `pnpm`) reported the kill as exit 128 + n, or the runner caught a
2217
- // worker's heap OOM and exited 1. Reported as a failing suite, and cached
2218
- // as one, it replayed a 3 GB OOM to a retry given 6 GB.
2219
- if (result.resourceKill !== null) {
2220
- return {
2221
- ok: false,
2222
- reason: `Tool "${tool}" → ${resolution.command.cmd} ${resolution.command.args.join(' ')} ` +
2223
- `RAN for ${Math.round(result.durationMs / 1000)}s in ${result.executionRoot} and was ` +
2224
- `KILLED for resources (${result.resourceKill}). This is not a verdict on the code, ` +
2225
- `and nothing was cached.${resourcePlanNote(result)} Raise the heap with ` +
2226
- `${HEAVY_HEAP_ENV}=<MiB> (an inherited NODE_OPTIONS heap above the run's budget is ` +
2227
- `clamped, T13122) or lower the worker count with ${HEAVY_WORKERS_ENV}=<n>, and verify ` +
2228
- `again — both are part of the cache key, so the retry runs fresh.` +
2229
- (result.stdoutTail || result.stderrTail
2230
- ? ` Last output: ${tailString(`${result.stdoutTail}\n${result.stderrTail}`, 512)}`
2231
- : ''),
2232
- codeName: 'E_EVIDENCE_TOOL_KILLED',
2233
- };
2234
- }
2254
+ // gh#1381 / T12989: killed after it started, for resources or by a signal.
2255
+ const killed = killedValidation(`Tool "${tool}" → ${resolution.command.cmd} ${resolution.command.args.join(' ')}`, resolution.command.canonical, result);
2256
+ if (killed !== null)
2257
+ return killed;
2235
2258
  // gh#1397: CLEO wraps `test` and `build` in a systemd scope of its own
2236
2259
  // making (T12116). When that wrapper cannot start, `systemd-run` exits 1
2237
2260
  // WITHOUT ever running the tool — and 1 is also what a suite with a failing
@@ -2295,7 +2318,7 @@ async function validateTool(tool, roots, context, siblingCommitSha) {
2295
2318
  tool,
2296
2319
  exitCode: 0,
2297
2320
  stdoutTail: result.stdoutTail,
2298
- ...toolRunAtomFields(result),
2321
+ ...(await toolRunAtomFields(result)),
2299
2322
  ...(fullScopeReason !== undefined
2300
2323
  ? { scope: 'full', ...(fullScopeReason ? { scopeReason: fullScopeReason } : {}) }
2301
2324
  : {}),
@@ -2308,9 +2331,13 @@ async function validateTool(tool, roots, context, siblingCommitSha) {
2308
2331
  * needed its one full retry — the `flaky` files, so a flaky pass stays
2309
2332
  * visible to gates instead of reading as a clean one.
2310
2333
  */
2311
- function toolRunAtomFields(result) {
2334
+ async function toolRunAtomFields(result) {
2335
+ // T13003: the HEAD the run measured, so boundHeadGapReason can refuse a run
2336
+ // on a checkout that lacks the task's implemented commits.
2337
+ const headSha = result.treeHash ? await captureHead(result.executionRoot) : null;
2312
2338
  return {
2313
2339
  ...(result.treeHash ? { treeHash: result.treeHash } : {}),
2340
+ ...(headSha ? { headSha } : {}),
2314
2341
  cacheHit: result.cacheHit,
2315
2342
  ...(result.flaky ? { flaky: result.flaky } : {}),
2316
2343
  // T13122: the heap and worker count the run got, and why — including an
@@ -2322,6 +2349,48 @@ function toolRunAtomFields(result) {
2322
2349
  * One sentence naming the heap and worker plan a memory-bound tool ran under
2323
2350
  * (T13122), for a message about a kill; `''` for any other tool.
2324
2351
  */
2352
+ /**
2353
+ * The `E_EVIDENCE_TOOL_KILLED` validation for a tool run that started and was
2354
+ * killed, by a signal or for resources (a wrapper's exit 128 + n, a heap OOM
2355
+ * the runner caught), else `null` (T13013). One message for every path that
2356
+ * runs a tool (`tool:<name>`, the affected-tests run, `cleo done`), naming the
2357
+ * systemd memory ceiling when one applies ({@link describeMemoryLimit}) and
2358
+ * where the heap is really set.
2359
+ *
2360
+ * gh#1381: a tool killed after it started is a different fact from a tool
2361
+ * that never started; "binary missing" sent operators hunting a binary that
2362
+ * was present.
2363
+ *
2364
+ * @param label - How the run is named: `Tool "test" → pnpm run test`.
2365
+ * @param canonical - The canonical tool, for the memory ceiling.
2366
+ * @param result - The run.
2367
+ * @returns The failed validation, or `null` when the run was not killed.
2368
+ *
2369
+ * @task T13013
2370
+ */
2371
+ export function killedValidation(label, canonical, result) {
2372
+ const how = result.signal !== null
2373
+ ? `by ${result.signal}`
2374
+ : result.resourceKill !== null
2375
+ ? `for resources (${result.resourceKill})`
2376
+ : null;
2377
+ if (how === null)
2378
+ return null;
2379
+ const output = `${result.stdoutTail}\n${result.stderrTail}`.trim();
2380
+ return {
2381
+ ok: false,
2382
+ reason: `${label} RAN for ${Math.round(result.durationMs / 1000)}s in ${result.executionRoot} and was ` +
2383
+ `KILLED ${how}. The command started, so this is not a resolution problem and not a ` +
2384
+ `verdict on the code; nothing was cached.${describeMemoryLimit(canonical)}` +
2385
+ `${resourcePlanNote(result)} Raise the heap with ${HEAVY_HEAP_ENV}=<MiB> or lower the ` +
2386
+ `worker count with ${HEAVY_WORKERS_ENV}=<n> (both are in the cache key, so the retry ` +
2387
+ `runs fresh). A heap set by the test config (a vitest execArgv such as ` +
2388
+ `--max-old-space-size in vitest.memory-safe) binds each worker and NODE_OPTIONS does ` +
2389
+ `not override it: raise it there.` +
2390
+ (output ? ` Last output: ${tailString(output, 512)}` : ''),
2391
+ codeName: 'E_EVIDENCE_TOOL_KILLED',
2392
+ };
2393
+ }
2325
2394
  function resourcePlanNote(result) {
2326
2395
  return result.resources ? ` It ran with ${result.resources.summary}.` : '';
2327
2396
  }
@@ -2738,12 +2807,17 @@ async function validatePrAtom(prNumber, roots, context, componentPrNumber) {
2738
2807
  restoredPaths = direct.restored;
2739
2808
  }
2740
2809
  if (context.gates.includes('implemented') &&
2741
- classifyEvidenceTask(context) === 'code' &&
2810
+ classifyEvidenceTask({ ...context, changedPaths }) === 'code' &&
2742
2811
  changedPaths.every(isDocumentArtifact)) {
2812
+ // T14065: name the supported reclassifications, not just the refusal.
2743
2813
  return {
2744
2814
  ok: false,
2745
2815
  codeName: 'E_EVIDENCE_CONTENT_MISMATCH',
2746
- reason: `PR #${linked.prNumber} changes only documentation and cannot implement code task ${context.task.id}. Changed artifacts: ${changedPaths.join(', ')}.`,
2816
+ reason: `PR #${linked.prNumber} changes only documentation and cannot implement code task ${context.task.id}. ` +
2817
+ `Changed artifacts: ${changedPaths.join(', ')}. If ${context.task.id} is a documentation task, ` +
2818
+ `declare its documents with \`cleo update ${context.task.id} --files ${changedPaths.join(',')}\` ` +
2819
+ `(declared documents decide), or label it with \`cleo update ${context.task.id} --add-labels docs\` ` +
2820
+ 'when it is not a bug and no declared file or acceptance criterion names code; then re-verify.',
2747
2821
  };
2748
2822
  }
2749
2823
  return {
@@ -2987,7 +3061,7 @@ export function checkTaskEvidenceContext(context, gate, atoms) {
2987
3061
  return refusal;
2988
3062
  }
2989
3063
  if (gate === 'implemented' &&
2990
- classifyEvidenceTask(context) === 'code' &&
3064
+ classifyEvidenceTask({ ...context, changedPaths: atom.changedPaths }) === 'code' &&
2991
3065
  atom.changedPaths.every(isDocumentArtifact))
2992
3066
  return 'Documentation-only PR evidence cannot implement the current code task.';
2993
3067
  }
@@ -3263,9 +3337,10 @@ export function composeGateEvidence(atoms, capturedBy, override, overrideReason,
3263
3337
  return result;
3264
3338
  }
3265
3339
  /**
3266
- * Why a `test-run:`/`qa-run:` result does not describe this task's work
3267
- * (T14004), or null. The binding records the HEAD it was bound to, and that
3268
- * HEAD must have seen the task's `implemented` work:
3340
+ * Why a `test-run:`/`qa-run:` result, or a `tool:` run (T13003), does not
3341
+ * describe this task's work (T14004), or null. The binding records the HEAD it
3342
+ * was bound to (a tool run, the HEAD it measured), and that HEAD must have seen
3343
+ * the task's `implemented` work:
3269
3344
  *
3270
3345
  * - `commit:` — the run head is that commit or contains it. An older commit
3271
3346
  * (the branch base, main before the work) never saw the work.
@@ -3273,6 +3348,10 @@ export function composeGateEvidence(atoms, capturedBy, override, overrideReason,
3273
3348
  * head: its recorded `headSha` (a squash merge too), else the merge's second
3274
3349
  * parent. An earlier branch commit never saw the whole change.
3275
3350
  *
3351
+ * "Seen" also holds when the HEAD carries a patch-equivalent commit
3352
+ * (`git cherry`): a rebase or a message-only amend keeps the patch, not the
3353
+ * SHA (T13003).
3354
+ *
3276
3355
  * Evidence bound in another task's worktree is refused even when `cleo
3277
3356
  * complete` runs from that same worktree. A commit git cannot read (a shallow
3278
3357
  * clone) is refused and said so. Skipped while no implemented commit is
@@ -3283,9 +3362,11 @@ export function composeGateEvidence(atoms, capturedBy, override, overrideReason,
3283
3362
  * @param projectRoot - The CLEO store root (the task's checkout is resolved from it).
3284
3363
  * @returns The refusal, or null.
3285
3364
  * @task T14004
3365
+ * @task T13003
3286
3366
  */
3287
3367
  export async function boundHeadGapReason(task, atoms, projectRoot) {
3288
- const runs = atoms.filter((a) => (a.kind === 'test-run' || a.kind === 'qa-run') && typeof a.headSha === 'string');
3368
+ const runs = atoms.filter((a) => (a.kind === 'test-run' || a.kind === 'qa-run' || a.kind === 'tool') &&
3369
+ typeof a.headSha === 'string');
3289
3370
  if (runs.length === 0)
3290
3371
  return null;
3291
3372
  const implemented = (task.verification?.evidence?.implemented?.atoms ?? []).filter((a) => a.kind === 'commit' || a.kind === 'pr');
@@ -3326,7 +3407,14 @@ export async function boundHeadGapReason(task, atoms, projectRoot) {
3326
3407
  };
3327
3408
  // A prefix match needs at least 7 hex characters on both sides: '' matches everything.
3328
3409
  const sameCommit = (a, b) => a.length >= 7 && b.length >= 7 && (a.startsWith(b) || b.startsWith(a));
3329
- const reached = (head, sha) => sameCommit(head, sha) || contains(head, sha);
3410
+ // T13003: a rebased or amended copy of the commit (same patch) in the run's
3411
+ // history. `git cherry` marks each commit of `sha^..sha` with `-` when the
3412
+ // head has its patch; every one must be marked (merges have no patch).
3413
+ const patchEquivalent = (head, sha) => {
3414
+ const marks = (gitOut(['cherry', head, sha, `${sha}^`]) ?? '').split('\n').filter(Boolean);
3415
+ return marks.length > 0 && marks.every((line) => line.startsWith('-'));
3416
+ };
3417
+ const reached = (head, sha) => sameCommit(head, sha) || contains(head, sha) || patchEquivalent(head, sha);
3330
3418
  // Only positive checks accept: the run head is, or contains, the work's tip.
3331
3419
  const sawWork = (head, atom) => {
3332
3420
  if (atom.kind === 'commit')
@@ -3348,7 +3436,9 @@ export async function boundHeadGapReason(task, atoms, projectRoot) {
3348
3436
  missing = null;
3349
3437
  if (implemented.every((atom) => sawWork(head, atom)))
3350
3438
  continue;
3351
- const bound = `${run.kind}:${run.path} was bound at ${head.slice(0, 12)}`;
3439
+ const bound = run.kind === 'tool'
3440
+ ? `tool:${run.tool} ran at ${head.slice(0, 12)}`
3441
+ : `${run.kind}:${run.path} was bound at ${head.slice(0, 12)}`;
3352
3442
  if (missing !== null) {
3353
3443
  return (`${bound}, and git cannot read the commits that relate it to ${task.id}'s implemented ` +
3354
3444
  `commit ${shas} (missing object, or a shallow clone: ${missing}). ` +
@@ -3379,6 +3469,8 @@ export async function boundHeadGapReason(task, atoms, projectRoot) {
3379
3469
  * on the default branch whose subject cites it, and its merged PRs). A kind
3380
3470
  * or label set by any agent never lets a code change skip its tests, and
3381
3471
  * work that cannot be inspected fails closed to `code`, with the reason.
3472
+ * A task {@link declaredEvidenceKind} leaves undecided is classified by that
3473
+ * same change set (T14048): documents only make it documentation.
3382
3474
  *
3383
3475
  * @param task - The task (kind, files, labels and recorded evidence are read).
3384
3476
  * @param projectRoot - The CLEO store root (commits are read in the task's checkout).
@@ -3390,9 +3482,11 @@ export async function boundHeadGapReason(task, atoms, projectRoot) {
3390
3482
  */
3391
3483
  export async function taskEvidenceKindReport(task, projectRoot, extraPaths = [], cwd, deps = {}) {
3392
3484
  const kind = classifyEvidenceTask({ task });
3393
- if (kind === 'code')
3485
+ // T14048: a task its record leaves undecided is classified by what it changed.
3486
+ const undecided = kind === 'code' && declaredEvidenceKind(task) === null;
3487
+ if (kind === 'code' && !undecided)
3394
3488
  return { kind };
3395
- const code = (reason) => ({ kind: 'code', reason });
3489
+ const code = (reason) => undecided ? { kind: 'code' } : { kind: 'code', reason };
3396
3490
  const { resolveChangeSetRoot, taskOwnWorkPaths } = await import('./change-set.js');
3397
3491
  // The task's own branches, commits and merged PRs, whatever its evidence
3398
3492
  // cites: a spike that commits code and records `decision:` owes tests.
@@ -3420,6 +3514,8 @@ export async function taskEvidenceKindReport(task, projectRoot, extraPaths = [],
3420
3514
  paths.push(...files);
3421
3515
  }
3422
3516
  }
3517
+ if (undecided)
3518
+ return { kind: classifyEvidenceTask({ task, changedPaths: paths }) };
3423
3519
  const codePath = paths.find((path) => !isDocumentEvidencePath(path));
3424
3520
  return codePath === undefined ? { kind } : code(`its work touches ${codePath}`);
3425
3521
  }