devflow-kit 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/agents/architect.md +91 -0
  3. package/dist/agents/code.md +21 -25
  4. package/dist/agents/debug.md +22 -50
  5. package/dist/agents/design.md +9 -43
  6. package/dist/agents/fix.md +15 -29
  7. package/dist/agents/git.md +19 -22
  8. package/dist/agents/harden.md +14 -19
  9. package/dist/agents/knowledge.md +11 -19
  10. package/dist/agents/refactor.md +9 -14
  11. package/dist/agents/research.md +11 -34
  12. package/dist/agents/review.md +22 -52
  13. package/dist/agents/skim.md +7 -17
  14. package/dist/agents/triage.md +7 -14
  15. package/dist/cli/commands/uninstall.js +1 -1
  16. package/dist/commands/bug-analysis.md +32 -63
  17. package/dist/commands/code-review.md +39 -88
  18. package/dist/commands/debug.md +32 -82
  19. package/dist/commands/dynamic-build.md +157 -211
  20. package/dist/commands/dynamic-plan.md +71 -93
  21. package/dist/commands/dynamic-profile.md +7 -155
  22. package/dist/commands/dynamic-tickets.md +65 -186
  23. package/dist/commands/explore.md +27 -70
  24. package/dist/commands/implement.md +101 -189
  25. package/dist/commands/plan.md +61 -108
  26. package/dist/commands/release.md +26 -92
  27. package/dist/commands/research.md +37 -106
  28. package/dist/commands/resolve.md +65 -129
  29. package/dist/commands/self-review.md +36 -100
  30. package/dist/compliance-off/agents/git.md +15 -18
  31. package/dist/compliance-off/agents/triage.md +7 -14
  32. package/dist/compliance-off/commands/bug-analysis.md +32 -63
  33. package/dist/compliance-off/commands/code-review.md +35 -83
  34. package/dist/compliance-off/commands/dynamic-build.md +151 -205
  35. package/dist/compliance-off/commands/dynamic-tickets.md +65 -186
  36. package/dist/compliance-off/commands/implement.md +97 -183
  37. package/dist/compliance-off/commands/plan.md +61 -108
  38. package/dist/compliance-off/commands/release.md +26 -91
  39. package/dist/compliance-off/commands/resolve.md +55 -129
  40. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +3 -3
  41. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -3
  42. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -3
  43. package/dist/core/plugins.js +3 -3
  44. package/dist/curation-compliance-off/agents/triage.md +7 -13
  45. package/dist/curation-compliance-off/commands/bug-analysis.md +22 -42
  46. package/dist/curation-compliance-off/commands/code-review.md +26 -63
  47. package/dist/curation-compliance-off/commands/dynamic-build.md +144 -194
  48. package/dist/curation-compliance-off/commands/dynamic-tickets.md +54 -173
  49. package/dist/curation-compliance-off/commands/implement.md +89 -164
  50. package/dist/curation-compliance-off/commands/plan.md +52 -87
  51. package/dist/curation-compliance-off/commands/release.md +18 -79
  52. package/dist/curation-compliance-off/commands/resolve.md +46 -109
  53. package/dist/curation-off/agents/architect.md +85 -0
  54. package/dist/curation-off/agents/code.md +20 -23
  55. package/dist/curation-off/agents/debug.md +20 -48
  56. package/dist/curation-off/agents/design.md +8 -41
  57. package/dist/curation-off/agents/fix.md +14 -27
  58. package/dist/curation-off/agents/harden.md +13 -18
  59. package/dist/curation-off/agents/knowledge.md +9 -17
  60. package/dist/curation-off/agents/refactor.md +8 -12
  61. package/dist/curation-off/agents/research.md +9 -32
  62. package/dist/curation-off/agents/review.md +11 -40
  63. package/dist/curation-off/agents/skim.md +7 -17
  64. package/dist/curation-off/agents/triage.md +7 -13
  65. package/dist/curation-off/commands/bug-analysis.md +22 -42
  66. package/dist/curation-off/commands/code-review.md +30 -68
  67. package/dist/curation-off/commands/debug.md +24 -63
  68. package/dist/curation-off/commands/dynamic-build.md +150 -200
  69. package/dist/curation-off/commands/dynamic-plan.md +60 -80
  70. package/dist/curation-off/commands/dynamic-tickets.md +54 -173
  71. package/dist/curation-off/commands/explore.md +18 -50
  72. package/dist/curation-off/commands/implement.md +93 -170
  73. package/dist/curation-off/commands/plan.md +52 -87
  74. package/dist/curation-off/commands/release.md +18 -80
  75. package/dist/curation-off/commands/research.md +26 -84
  76. package/dist/curation-off/commands/resolve.md +56 -109
  77. package/dist/curation-off/commands/self-review.md +27 -79
  78. package/dist/hud/components/version-badge.js +1 -1
  79. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -1
  80. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +3 -3
  81. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +3 -3
  82. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +3 -3
  83. package/dist/targets/claude-code/curation-install.js +2 -2
  84. package/dist/variant-axes.json +5 -1
  85. package/package.json +1 -1
  86. package/src/assets/agents/_partials/_code.mds +3 -5
  87. package/src/assets/agents/_partials/_design.mds +86 -0
  88. package/src/assets/agents/architect.mds +45 -0
  89. package/src/assets/agents/code.mds +18 -20
  90. package/src/assets/agents/curate.md +26 -35
  91. package/src/assets/agents/debug.mds +22 -50
  92. package/src/assets/agents/design.mds +11 -114
  93. package/src/assets/agents/evaluate.md +6 -16
  94. package/src/assets/agents/explore.md +1 -2
  95. package/src/assets/agents/fix.mds +12 -24
  96. package/src/assets/agents/git.mds +20 -23
  97. package/src/assets/agents/harden.mds +14 -19
  98. package/src/assets/agents/knowledge.mds +10 -38
  99. package/src/assets/agents/polish.md +13 -39
  100. package/src/assets/agents/refactor.mds +6 -9
  101. package/src/assets/agents/research.mds +11 -34
  102. package/src/assets/agents/review.mds +12 -84
  103. package/src/assets/agents/skim.mds +7 -17
  104. package/src/assets/agents/synthesize.md +3 -21
  105. package/src/assets/agents/test.md +31 -32
  106. package/src/assets/agents/tracker.md +116 -229
  107. package/src/assets/agents/triage.mds +7 -26
  108. package/src/assets/agents/validate.md +1 -3
  109. package/src/assets/commands/_partials/_compliance.mds +2 -2
  110. package/src/assets/commands/_partials/_decisions.mds +17 -24
  111. package/src/assets/commands/_partials/_docs_root.mds +31 -10
  112. package/src/assets/commands/_partials/_engine.mds +36 -56
  113. package/src/assets/commands/_partials/_evidence_policy.mds +1 -1
  114. package/src/assets/commands/_partials/_factory.mds +10 -104
  115. package/src/assets/commands/_partials/_knowledge.mds +48 -52
  116. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  117. package/src/assets/commands/_partials/_preamble.mds +21 -21
  118. package/src/assets/commands/_partials/_publication.mds +3 -3
  119. package/src/assets/commands/_partials/_roster.mds +12 -13
  120. package/src/assets/commands/_partials/_settings.mds +6 -3
  121. package/src/assets/commands/_partials/_ticket_template.mds +3 -7
  122. package/src/assets/commands/_partials/_tracker.mds +10 -5
  123. package/src/assets/commands/_partials/_wave.mds +23 -32
  124. package/src/assets/commands/bug-analysis.mds +3 -7
  125. package/src/assets/commands/code-review.mds +12 -33
  126. package/src/assets/commands/debug.mds +5 -47
  127. package/src/assets/commands/dynamic-build.mds +48 -70
  128. package/src/assets/commands/dynamic-plan.mds +23 -42
  129. package/src/assets/commands/dynamic-profile.mds +7 -57
  130. package/src/assets/commands/dynamic-tickets.mds +9 -29
  131. package/src/assets/commands/explore.mds +5 -44
  132. package/src/assets/commands/implement.mds +77 -119
  133. package/src/assets/commands/plan.mds +27 -57
  134. package/src/assets/commands/release.mds +4 -62
  135. package/src/assets/commands/research.mds +9 -55
  136. package/src/assets/commands/resolve.mds +40 -87
  137. package/src/assets/commands/self-review.mds +5 -45
  138. package/src/assets/mds/git/_ops_pr.mds +1 -0
  139. package/src/assets/mds/git/_ops_steps.mds +4 -4
  140. package/src/assets/mds/git/_pr.mds +1 -1
  141. package/src/assets/rules/engineering.md +1 -1
  142. package/src/assets/rules/quality.md +2 -2
  143. package/src/assets/scripts/ci-wait.cjs +154 -19
  144. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +3 -3
  145. package/src/assets/scripts/hooks/preamble +2 -3
  146. package/src/assets/scripts/pr-evidence.cjs +1 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +14 -13
  148. package/src/assets/scripts/resolve-root.cjs +190 -0
  149. package/src/assets/scripts/resolve-settings.cjs +14 -9
  150. package/src/assets/scripts/verify-evidence.cjs +551 -25
  151. package/src/assets/skills/accessibility/SKILL.md +28 -95
  152. package/src/assets/skills/agent-references/SKILL.md +2 -4
  153. package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +1 -1
  154. package/src/assets/skills/agent-references/references/commands/implement-levels.md +4 -7
  155. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +1 -1
  156. package/src/assets/skills/agent-references/references/commands/plan-levels.md +2 -2
  157. package/src/assets/skills/apply-decisions/SKILL.md +5 -6
  158. package/src/assets/skills/apply-feature-knowledge/SKILL.md +3 -13
  159. package/src/assets/skills/architecture/SKILL.md +29 -105
  160. package/src/assets/skills/boundary-validation/SKILL.md +27 -78
  161. package/src/assets/skills/complexity/SKILL.md +8 -104
  162. package/src/assets/skills/compliance/SKILL.md +9 -67
  163. package/src/assets/skills/consistency/SKILL.md +7 -107
  164. package/src/assets/skills/database/SKILL.md +7 -72
  165. package/src/assets/skills/dependencies/SKILL.md +9 -109
  166. package/src/assets/skills/dependencies/references/detection.md +10 -0
  167. package/src/assets/skills/dependency-research/SKILL.md +9 -63
  168. package/src/assets/skills/design-review/SKILL.md +14 -37
  169. package/src/assets/skills/docs-framework/SKILL.md +4 -47
  170. package/src/assets/skills/documentation/SKILL.md +7 -71
  171. package/src/assets/skills/documentation/references/patterns.md +1 -1
  172. package/src/assets/skills/documentation/references/violations.md +1 -1
  173. package/src/assets/skills/feature-knowledge/SKILL.md +19 -222
  174. package/src/assets/skills/feature-knowledge/references/new-kb.md +128 -0
  175. package/src/assets/skills/gap-analysis/SKILL.md +1 -31
  176. package/src/assets/skills/git/SKILL.md +2 -22
  177. package/src/assets/skills/go/SKILL.md +24 -113
  178. package/src/assets/skills/java/SKILL.md +12 -98
  179. package/src/assets/skills/patterns/SKILL.md +8 -93
  180. package/src/assets/skills/performance/SKILL.md +29 -88
  181. package/src/assets/skills/python/SKILL.md +22 -120
  182. package/src/assets/skills/qa/SKILL.md +16 -54
  183. package/src/assets/skills/qa/references/browser-testing.md +0 -24
  184. package/src/assets/skills/qa/references/sources.md +2 -2
  185. package/src/assets/skills/quality-gates/SKILL.md +13 -100
  186. package/src/assets/skills/react/SKILL.md +33 -95
  187. package/src/assets/skills/regression/SKILL.md +8 -73
  188. package/src/assets/skills/reliability/SKILL.md +11 -120
  189. package/src/assets/skills/research-codebase/SKILL.md +7 -49
  190. package/src/assets/skills/research-competitor/SKILL.md +4 -39
  191. package/src/assets/skills/research-external/SKILL.md +5 -43
  192. package/src/assets/skills/research-market/SKILL.md +5 -43
  193. package/src/assets/skills/research-technology/SKILL.md +8 -68
  194. package/src/assets/skills/review-methodology/SKILL.md +10 -56
  195. package/src/assets/skills/rust/SKILL.md +19 -109
  196. package/src/assets/skills/security/SKILL.md +39 -66
  197. package/src/assets/skills/software-design/SKILL.md +20 -105
  198. package/src/assets/skills/software-design/references/patterns.md +84 -2
  199. package/src/assets/skills/software-design/references/violations.md +1 -1
  200. package/src/assets/skills/test-driven-development/SKILL.md +32 -107
  201. package/src/assets/skills/test-driven-development/references/rationalization-prevention.md +33 -1
  202. package/src/assets/skills/testing/SKILL.md +35 -53
  203. package/src/assets/skills/typescript/SKILL.md +23 -102
  204. package/src/assets/skills/ui-design/SKILL.md +26 -74
  205. package/src/assets/skills/worktree-support/SKILL.md +4 -10
  206. package/dist/curation-off/commands/dynamic-profile.md +0 -214
@@ -19,6 +19,8 @@
19
19
  // node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]
20
20
  // node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]
21
21
  // [--evidence <file>] [--out <file>]
22
+ // node verify-evidence.cjs claim --evidence <file> --head <sha> [--plan <file>]
23
+ // gate | TP-<n>=<PASS|FAIL|SKIP>[:<exit>] ...
22
24
  //
23
25
  // stdout (D-VERIFY-STDOUT) is empty or exactly one of these shapes, each closed-vocabulary
24
26
  // (the render block's TP lines come from the caller's own plan file):
@@ -33,10 +35,14 @@
33
35
  // posted:<yes|no|n/a> body:<same|changed> (one line; wrapped here)
34
36
  // splice SPLICE ok|resplice (exit 0) or SPLICE conflict|malformed|oversize (exit 5)
35
37
  // readback READBACK ok (exit 0) or READBACK mismatch (exit 5)
36
- // publish the `## PR Evidence` block — the heading, the EVIDENCE line, one
38
+ // publish the `## PR Evidence` block — the heading, the EVIDENCE line, the line
39
+ // `Test plan: <v>/<total> verified (VERIFIED-CI <a>, ATTESTED-LOCAL <b>)`
40
+ // (v is a + b, all three read from the EVIDENCE line), one
37
41
  // `**Body**: … · **Comment**: …` line — then one `TRACEABILITY: DEGRADED
38
42
  // (<reason>)` line per degraded part (exit 0), or exit 2 with one line:
39
- // `TRACEABILITY: DEGRADED (evidence unavailable)` when it could not run,
43
+ // `TRACEABILITY: DEGRADED (evidence unavailable)` when it could not run (a
44
+ // usage error, an internal error, a failed read and a block the output gate
45
+ // refuses all end this way, so a `publish` never ends with empty stdout),
40
46
  // `TRACEABILITY: DEGRADED (compliance off)` when the machine manifest does not
41
47
  // say compliance is enabled, or `TRACEABILITY: DEGRADED (compliance state
42
48
  // unreadable)` when that manifest cannot be read. The last two come before
@@ -55,10 +61,34 @@
55
61
  // is removed before the first read, and a block that does not parse or a
56
62
  // write that fails leaves no out file. The one exception is a path that
57
63
  // git tracks (below), which is left as it is
64
+ // claim D-CLAIM-APPEND: appends claim lines to the `## Claims` section of the
65
+ // `--evidence` file. `gate` writes `- gate:validate PASS sha:<head>
66
+ // by:validate`. Each `TP-<n>=<outcome>[:<exit>]` writes `- TP-<n> <outcome>
67
+ // sha:<head> by:test[ exit:<exit>]`. `--head` is one 40-hex SHA, else
68
+ // nothing is written. The `exit:` part is written only for a number from 0
69
+ // to 255. A TP that the plan does not list gets no line. The plan is the
70
+ // `## Test Plan` of the `--plan` file, or of the evidence file when `--plan`
71
+ // is absent. The `## Claims` section is created when absent, at the end
72
+ // of the file; when it is not the last section the lines go at its end
73
+ // (D-CLAIM-INSERT). A missing folder inside the working directory is
74
+ // created, and so is the file (D-CLAIM-FOLDER). The write is one append of
75
+ // every line, or, for a Claims section that is not last, one whole-file
76
+ // replace through a sibling temporary file that keeps the file's mode. The
77
+ // parser of pr-evidence.cjs accepts each line before it. It prints one
78
+ // line, exit 0:
79
+ // `CLAIM appended:<n> PASS:<n> FAIL:<n> SKIP:<n> skipped:<n>`
80
+ // (`skipped` counts the TPs that got no line), or exit 2 with
81
+ // `CLAIM refused (<reason>)`, the reason being `head not one sha`,
82
+ // `evidence file tracked` (git tracks it, or cannot say it does not),
83
+ // `evidence file unusable` (a file that does not parse, or a folder that is
84
+ // missing outside the working directory) or `plan file unusable`. A refusal
85
+ // writes nothing. A folder the run made is removed again when the run
86
+ // leaves no file in it
58
87
  // A git-tracked file counts as absent, like a tracked `.devflow/config.json`
59
88
  // (D-TRACKED-RESULT). `verify`, `publish` and `readiness` ask git about an `--evidence`
60
89
  // file before they read it, and about `--block-out`, `--comment-out`, `--stale-out` and
61
- // `--out` before they write or remove one. Only a clear "not tracked" answer lets the
90
+ // `--out` before they write or remove one. `claim` asks about its `--evidence` and
91
+ // `--plan` files, and refuses both when either is not clearly untracked. Only a clear "not tracked" answer lets the
62
92
  // file be read or written. A tracked `--evidence` file, or one git cannot answer for, is
63
93
  // dropped: the run goes on exactly as if `--evidence` was not given, and the stderr
64
94
  // summary carries the note `evidence-ignored`. A tracked out path, or one git cannot
@@ -68,14 +98,16 @@
68
98
  //
69
99
  // Exit codes:
70
100
  // 0 ok
71
- // 1 usage error — stdout entirely empty, usage on stderr
101
+ // 1 usage error — stdout entirely empty, usage on stderr (a `publish` usage error is
102
+ // exit 2 with the evidence unavailable line instead)
72
103
  // 2 input unusable — a file could not be read (missing, not a regular file,
73
104
  // oversize, not UTF-8), the evidence file or the state directory is
74
105
  // malformed, or the PR body's test-plan block cannot serve as the plan.
75
106
  // For `publish` and `readiness` (D-OP-SCRIPT-POINTER) it is the DEGRADED
76
107
  // result: the operation could not read what it needs, and the stdout above
77
- // says so
78
- // 3 output write failed — no stdout line is printed
108
+ // says so. For `claim` it is the refusal line above
109
+ // 3 output write failed — no stdout line is printed (a `claim` write leaves the
110
+ // evidence file as it was)
79
111
  // 4 remote or internal failure before any verdict — `gh pr view` failed or
80
112
  // answered something unusable, or an unexpected internal error
81
113
  // 5 output gate refused — `check` found the file invalid, the EVIDENCE line
@@ -223,6 +255,12 @@ const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMIS
223
255
  const SPLICE_LINE_RE = /^SPLICE (?<outcome>ok|resplice|conflict|malformed|oversize)$/;
224
256
  const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
225
257
 
258
+ /** D-CLAIM-APPEND: the stdout shapes of `claim`, and the closed set of its refusal reasons. */
259
+ const CLAIM_APPENDED_RE = /^CLAIM appended:(?<n>[0-9]{1,3}) PASS:(?<pass>[0-9]{1,3}) FAIL:(?<fail>[0-9]{1,3}) SKIP:(?<skip>[0-9]{1,3}) skipped:(?<skipped>[0-9]{1,3})$/;
260
+ const CLAIM_REFUSAL_RE = /^CLAIM refused \((?<reason>head not one sha|evidence file tracked|evidence file unusable|plan file unusable)\)$/;
261
+ /** One `claim` argument: the TP, its outcome, and an exit the script keeps only as a number from 0 to 255. */
262
+ const CLAIM_ARG_RE = /^TP-(?<n>[1-9][0-9]{0,2})=(?<outcome>PASS|FAIL|SKIP)(?::(?<exit>[^\s]{1,12}))?$/;
263
+
226
264
  const USAGE = [
227
265
  'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
228
266
  ' node verify-evidence.cjs check wave --links-only <file>',
@@ -234,6 +272,8 @@ const USAGE = [
234
272
  ' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
235
273
  ' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
236
274
  ' [--evidence <file>] [--out <file>]',
275
+ ' node verify-evidence.cjs claim --evidence <file> --head <sha> [--plan <file>]',
276
+ ' gate | TP-<n>=<PASS|FAIL|SKIP>[:<exit>] ...',
237
277
  ].join('\n');
238
278
 
239
279
  // ---------------------------------------------------------------------------
@@ -253,11 +293,12 @@ const USAGE = [
253
293
  * @typedef {{ ghCalls: number, runViews: number, throttled: boolean }} Budget
254
294
  *
255
295
  * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv, cwd: string, now: () => number,
256
- * deadline: number, budget: Budget, notes: Set<string>, stderr: (text: string) => void,
257
- * ghCap?: number }} Io
296
+ * deadline: number, budget: Budget, notes: Set<string>, made: string[],
297
+ * stderr: (text: string) => void, ghCap?: number }} Io
258
298
  * The imperative shell's one context: every call goes through it, so every
259
299
  * bound is counted in one place. `notes` holds closed tokens for the stderr
260
- * summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
300
+ * summary, never input bytes. `made` holds the folders a `claim` run created, deepest
301
+ * first (D-CLAIM-FOLDER). `ghCap` replaces CAPS.GH_CALLS for a stage that
261
302
  * carries a budget of its own (`publish`'s writes).
262
303
  *
263
304
  * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean, commentId: string }} PrComment
@@ -290,7 +331,9 @@ const USAGE = [
290
331
  * | { kind: 'readback', pr: number, expect: string }
291
332
  * | { kind: 'publish', pr: number, publication: string, evidence: string | null }
292
333
  * | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised',
293
- * evidence: string | null, out: string | null }} ParsedArgs
334
+ * evidence: string | null, out: string | null }
335
+ * | { kind: 'claim', evidence: string, head: string, plan: string | null, gate: boolean,
336
+ * tps: readonly { id: number, outcome: 'PASS' | 'FAIL' | 'SKIP', exit: string | null }[] }} ParsedArgs
294
337
  */
295
338
 
296
339
  /** @type {ParsedArgs} */
@@ -423,9 +466,45 @@ function parseArgs(argv) {
423
466
  // An absent flag is the ladder's "unrecognised": it can never read READY.
424
467
  return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed, evidence, out };
425
468
  }
469
+ if (sub === 'claim') return parseClaimArgs(tail);
426
470
  return USAGE_ARGS;
427
471
  }
428
472
 
473
+ /**
474
+ * D-CLAIM-APPEND: `claim` takes its flags first, then either the one word `gate` or
475
+ * one to CAPS.TPS `TP-<n>=<outcome>[:<exit>]` arguments, each TP at most once. A bad
476
+ * flag or argument is a usage error. `--head` is not checked here: a value that is not
477
+ * one SHA is a refusal with a line, since the caller passes what its report shows.
478
+ *
479
+ * @param {readonly string[]} tail
480
+ * @returns {ParsedArgs}
481
+ */
482
+ function parseClaimArgs(tail) {
483
+ let flagCount = 0;
484
+ while (flagCount < tail.length && tail[flagCount].startsWith('--')) flagCount += 2;
485
+ const f = parseFlags(tail.slice(0, flagCount), { '--evidence': 'value', '--head': 'value', '--plan': 'value' });
486
+ const evidence = f === null ? null : flagValue(f, '--evidence');
487
+ const head = f === null ? null : flagValue(f, '--head');
488
+ const plan = f === null ? null : flagValue(f, '--plan');
489
+ const items = tail.slice(flagCount);
490
+ if (evidence === null || head === null || items.length === 0 || items.length > CAPS.TPS) return USAGE_ARGS;
491
+ if (items.length === 1 && items[0] === 'gate') {
492
+ return plan === null ? { kind: 'claim', evidence, head, plan, gate: true, tps: [] } : USAGE_ARGS;
493
+ }
494
+ const tps = [];
495
+ for (const item of items) {
496
+ const m = CLAIM_ARG_RE.exec(item);
497
+ if (m === null || m.groups === undefined) return USAGE_ARGS;
498
+ tps.push({
499
+ id: Number(m.groups.n),
500
+ outcome: /** @type {'PASS' | 'FAIL' | 'SKIP'} */ (m.groups.outcome),
501
+ exit: m.groups.exit === undefined ? null : m.groups.exit,
502
+ });
503
+ }
504
+ if (new Set(tps.map(tp => tp.id)).size !== tps.length) return USAGE_ARGS;
505
+ return { kind: 'claim', evidence, head, plan, gate: false, tps };
506
+ }
507
+
429
508
  // ---------------------------------------------------------------------------
430
509
  // Bounded file I/O
431
510
  // ---------------------------------------------------------------------------
@@ -516,6 +595,97 @@ function writeTextFile(filePath, text) {
516
595
  }
517
596
  }
518
597
 
598
+ /**
599
+ * Append `text` to `filePath` in one write, creating the file when it is absent. The same
600
+ * refusals as writeTextFile apply: a path that exists and is not a regular file, a link
601
+ * put in its place (O_NOFOLLOW), a FIFO (O_NONBLOCK). A write that does not complete
602
+ * cuts the file back to its length before the append, or removes the file when this call
603
+ * created it, so a failed append leaves no part of `text`. Returns whether it was written.
604
+ *
605
+ * @param {string} filePath
606
+ * @param {string} text
607
+ * @returns {boolean}
608
+ */
609
+ function appendTextFile(filePath, text) {
610
+ let fd;
611
+ let created = false;
612
+ try {
613
+ const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
614
+ if (st !== undefined && !st.isFile()) return false;
615
+ created = st === undefined;
616
+ const c = fs.constants;
617
+ fd = fs.openSync(filePath, c.O_WRONLY | c.O_APPEND | c.O_CREAT | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0), 0o600);
618
+ const before = fs.fstatSync(fd);
619
+ if (!before.isFile()) return false;
620
+ const bytes = Buffer.from(text, 'utf8');
621
+ try {
622
+ if (fs.writeSync(fd, bytes) === bytes.length) return true;
623
+ } catch (_) { /* cut back below */ }
624
+ try { fs.ftruncateSync(fd, before.size); } catch (_) { /* the write already failed */ }
625
+ if (created) fs.unlinkSync(filePath);
626
+ return false;
627
+ } catch (_) {
628
+ return false;
629
+ } finally {
630
+ if (fd !== undefined) {
631
+ try { fs.closeSync(fd); } catch (_) { /* the write already decided */ }
632
+ }
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Replace the regular file `filePath` with `text` and keep its `mode`: the text goes to a
638
+ * sibling temporary file, in the same folder, which a rename then puts in the file's place.
639
+ * It refuses first what appendTextFile refuses, so the two writes of a claim agree: a file
640
+ * this process may not write, a link put in its place (O_NOFOLLOW), a FIFO (O_NONBLOCK) or
641
+ * any other object that is not a regular file. The probe opens the file for an append and
642
+ * writes nothing. A write that fails leaves the file as it was and the temporary file
643
+ * removed, so a failed replace never truncates it. The temporary file is created new
644
+ * (O_EXCL) and never through a link (O_NOFOLLOW), and takes the old mode on its own
645
+ * descriptor, since the umask would otherwise set the new file's mode. Returns whether the
646
+ * file was replaced.
647
+ *
648
+ * @param {string} filePath
649
+ * @param {string} text
650
+ * @param {number} mode the permission bits of the file it replaces
651
+ * @returns {boolean}
652
+ */
653
+ function replaceTextFile(filePath, text, mode) {
654
+ const c = fs.constants;
655
+ let probe;
656
+ try {
657
+ probe = fs.openSync(filePath, c.O_WRONLY | c.O_APPEND | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0));
658
+ if (!fs.fstatSync(probe).isFile()) return false;
659
+ } catch (_) {
660
+ return false;
661
+ } finally {
662
+ if (probe !== undefined) {
663
+ try { fs.closeSync(probe); } catch (_) { /* the probe wrote nothing */ }
664
+ }
665
+ }
666
+ const temp = path.join(path.dirname(filePath), '.' + path.basename(filePath) + '.claim-' + process.pid);
667
+ let fd;
668
+ try {
669
+ fd = fs.openSync(temp, c.O_WRONLY | c.O_CREAT | c.O_EXCL | (c.O_NOFOLLOW || 0), 0o600);
670
+ } catch (_) {
671
+ return false;
672
+ }
673
+ try {
674
+ fs.fchmodSync(fd, mode);
675
+ fs.writeFileSync(fd, text, 'utf8');
676
+ fs.closeSync(fd);
677
+ fd = undefined;
678
+ fs.renameSync(temp, filePath);
679
+ return true;
680
+ } catch (_) {
681
+ if (fd !== undefined) {
682
+ try { fs.closeSync(fd); } catch (_) { /* the write already failed */ }
683
+ }
684
+ try { fs.unlinkSync(temp); } catch (_) { /* the temporary file is already gone */ }
685
+ return false;
686
+ }
687
+ }
688
+
519
689
  /**
520
690
  * @param {string} dir
521
691
  * @returns {boolean}
@@ -753,13 +923,18 @@ function trackedState(io, file) {
753
923
  * the write failure (exit 3): no file is written. An `--evidence` file that is not
754
924
  * clearly untracked is dropped, so the run goes on exactly as if `--evidence` was not
755
925
  * given; the stderr summary then carries the closed note `evidence-ignored`. Stdout
756
- * keeps its grammar.
926
+ * keeps its grammar. A `claim` run has its own refusal line for each of these cases
927
+ * (claimRefusalReason).
757
928
  *
758
929
  * @param {Io} io
759
930
  * @param {ParsedArgs} args
760
931
  * @returns {{ args: ParsedArgs } | { outcome: Outcome }}
761
932
  */
762
933
  function admitPaths(io, args) {
934
+ if (args.kind === 'claim') {
935
+ const reason = claimRefusalReason(io, args);
936
+ return reason === null ? { args } : { outcome: claimRefusal(reason) };
937
+ }
763
938
  if (args.kind !== 'verify' && args.kind !== 'publish' && args.kind !== 'readiness') return { args };
764
939
  const outs = args.kind === 'verify' ? [args.blockOut, args.commentOut, args.staleOut]
765
940
  : args.kind === 'readiness' ? [args.out] : [];
@@ -1424,7 +1599,16 @@ function runsAt(io, memo, sha) {
1424
1599
  */
1425
1600
  function loadEvidence(file) {
1426
1601
  const text = readTextFile(file);
1427
- if (text === null) return null;
1602
+ return text === null ? null : parseEvidenceText(text);
1603
+ }
1604
+
1605
+ /**
1606
+ * The sections of an evidence file's text, parsed (see loadEvidence).
1607
+ *
1608
+ * @param {string} text
1609
+ * @returns {EvidenceInput | null} null when unusable
1610
+ */
1611
+ function parseEvidenceText(text) {
1428
1612
  const sections = PE.evidenceSections(text);
1429
1613
  if (!sections.ok) return null;
1430
1614
  let plan = null;
@@ -2026,6 +2210,21 @@ const COMPLIANCE_UNREADABLE_LINE = 'TRACEABILITY: DEGRADED (compliance state unr
2026
2210
  /** Every line a `publish` that did not run may print. The boundary admits each one with exit 2 only. */
2027
2211
  const PUBLISH_REFUSAL_LINES = Object.freeze([EVIDENCE_UNAVAILABLE_LINE, COMPLIANCE_OFF_LINE, COMPLIANCE_UNREADABLE_LINE]);
2028
2212
 
2213
+ /** The answer of every `publish` that could not give its block: the one line, exit 2. */
2214
+ const PUBLISH_UNAVAILABLE = Object.freeze({ code: EXIT_CODES.INPUT_UNUSABLE, stdout: EVIDENCE_UNAVAILABLE_LINE + '\n' });
2215
+
2216
+ /**
2217
+ * Whether argv asks for `publish`. The boundary reads it after any failure, a usage
2218
+ * error and a throw included, so settleOutcome can answer a failed `publish` with
2219
+ * PUBLISH_UNAVAILABLE.
2220
+ *
2221
+ * @param {readonly string[]} argv process.argv
2222
+ * @returns {boolean}
2223
+ */
2224
+ function isPublishRun(argv) {
2225
+ return Array.isArray(argv) && argv[2] === 'publish';
2226
+ }
2227
+
2029
2228
  /**
2030
2229
  * @typedef {{ status: string, reason: string | null }} Part
2031
2230
  * One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
@@ -2077,8 +2276,26 @@ function traceReasons(body, comment) {
2077
2276
  }
2078
2277
 
2079
2278
  /**
2080
- * The `## PR Evidence` block of the update-pr-evidence contract, then its TRACEABILITY
2081
- * lines.
2279
+ * The test-plan tally that both scripted blocks print: `<verified>/<total>`, the given
2280
+ * word, then the two counts that make up `verified`. The readiness block prints it with
2281
+ * no word and the publish block with ` verified`.
2282
+ *
2283
+ * @param {{ total: number, counts: Readonly<Record<string, number>> }} fields the parsed EVIDENCE line
2284
+ * @param {'' | ' verified'} word
2285
+ * @returns {string}
2286
+ */
2287
+ function planTally(fields, word) {
2288
+ const verifiedCi = fields.counts['VERIFIED-CI'];
2289
+ const attestedLocal = fields.counts['ATTESTED-LOCAL'];
2290
+ return (verifiedCi + attestedLocal) + '/' + fields.total + word
2291
+ + ' (VERIFIED-CI ' + verifiedCi + ', ATTESTED-LOCAL ' + attestedLocal + ')';
2292
+ }
2293
+
2294
+ /**
2295
+ * The `## PR Evidence` block of the update-pr-evidence contract: the EVIDENCE line, the
2296
+ * `Test plan:` line read from it, the Body and Comment line, then its TRACEABILITY lines.
2297
+ * The caller holds the precondition that `evidenceLine` parses; a line that does not is a
2298
+ * broken invariant, which the catch in main turns into "evidence unavailable".
2082
2299
  *
2083
2300
  * @param {string} evidenceLine
2084
2301
  * @param {Part} body
@@ -2086,9 +2303,12 @@ function traceReasons(body, comment) {
2086
2303
  * @returns {string}
2087
2304
  */
2088
2305
  function renderPublishBlock(evidenceLine, body, comment) {
2306
+ const evidence = PE.parseEvidenceLine(evidenceLine);
2307
+ if (!evidence.ok) throw new Error('verify-evidence: unchecked evidence line');
2089
2308
  const lines = [
2090
2309
  '## PR Evidence',
2091
2310
  evidenceLine,
2311
+ 'Test plan: ' + planTally(evidence.value, ' verified'),
2092
2312
  '**Body**: ' + partText(body) + ' · **Comment**: ' + partText(comment),
2093
2313
  ...traceReasons(body, comment).map(r => 'TRACEABILITY: DEGRADED (' + r + ')'),
2094
2314
  ];
@@ -2110,8 +2330,8 @@ function parsePart(text, statuses) {
2110
2330
 
2111
2331
  /**
2112
2332
  * D-OP-BLOCK-GATE: read a `publish` block back into its parts, or null when it is not
2113
- * exactly what renderPublishBlock prints. The boundary prints a block only when this
2114
- * accepts it.
2333
+ * exactly what renderPublishBlock prints, so a `Test plan:` line that disagrees with the
2334
+ * EVIDENCE line is refused. The boundary prints a block only when this accepts it.
2115
2335
  *
2116
2336
  * @param {string} text the whole stdout, its final newline included
2117
2337
  * @returns {{ evidenceLine: string, body: Part, comment: Part } | null}
@@ -2119,9 +2339,9 @@ function parsePart(text, statuses) {
2119
2339
  function parsePublishBlock(text) {
2120
2340
  if (typeof text !== 'string' || !text.endsWith('\n') || text.length > 8192) return null;
2121
2341
  const lines = text.slice(0, -1).split('\n');
2122
- if (lines.length < 3 || lines.length > 5 || lines[0] !== '## PR Evidence') return null;
2342
+ if (lines.length < 4 || lines.length > 6 || lines[0] !== '## PR Evidence') return null;
2123
2343
  if (!PE.parseEvidenceLine(lines[1]).ok) return null;
2124
- const m = PUBLISH_PARTS_RE.exec(lines[2]);
2344
+ const m = PUBLISH_PARTS_RE.exec(lines[3]);
2125
2345
  if (m === null || m.groups === undefined) return null;
2126
2346
  const body = parsePart(m.groups.body, BODY_STATUSES);
2127
2347
  const comment = parsePart(m.groups.comment, COMMENT_STATUSES);
@@ -2138,7 +2358,7 @@ function parsePublishBlock(text) {
2138
2358
  */
2139
2359
  function evidenceUnavailable(io, reason) {
2140
2360
  io.stderr('verify-evidence: publish: ' + reason + '\n');
2141
- return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: EVIDENCE_UNAVAILABLE_LINE + '\n' };
2361
+ return PUBLISH_UNAVAILABLE;
2142
2362
  }
2143
2363
 
2144
2364
  /**
@@ -2826,9 +3046,7 @@ function readinessPass(io, args, deps) {
2826
3046
  : String(threads.value.unresolved),
2827
3047
  decision: decisionText,
2828
3048
  ci: ci.status,
2829
- plan: evidence === null ? 'unavailable'
2830
- : (evidence.verifiedCi + evidence.attestedLocal) + '/' + evidence.total
2831
- + ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
3049
+ plan: fields === null ? 'unavailable' : planTally(fields, ''),
2832
3050
  approval: approvalText(args.requireNonAuthor, evidence),
2833
3051
  };
2834
3052
  return { outcome: { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) }, head };
@@ -2922,6 +3140,289 @@ function runReadiness(io, args, deps) {
2922
3140
  return args.out === null ? pass.outcome : writeReadinessOut(io, args.out, args.pr, pass.outcome, pass.head, planSource(args));
2923
3141
  }
2924
3142
 
3143
+ // ---------------------------------------------------------------------------
3144
+ // claim — D-CLAIM-APPEND
3145
+ // ---------------------------------------------------------------------------
3146
+ //
3147
+ // A Test or Validate report becomes claim lines in the evidence file. The caller passes
3148
+ // only the facts of its report (the HEAD, the gate, one outcome per TP). This script owns
3149
+ // the rest: the line shapes (the claim parser of pr-evidence.cjs accepts each one before
3150
+ // it is written), the HEAD rule, the exit range, the `## Claims` section and the append.
3151
+ // A claim is an input to `verify`, never a state: only pr-evidence.cjs and `verify`
3152
+ // assign states.
3153
+
3154
+ /**
3155
+ * @param {string} reason one of the CLAIM_REFUSAL_RE reasons
3156
+ * @returns {Outcome}
3157
+ */
3158
+ function claimRefusal(reason) {
3159
+ return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: 'CLAIM refused (' + reason + ')\n' };
3160
+ }
3161
+
3162
+ /** The most folders one path can hold: the bound of the walk up to the nearest folder that exists. */
3163
+ const FOLDER_DEPTH_MAX = 4096;
3164
+
3165
+ /**
3166
+ * Whether `child` lies below `parent`, by name alone. `parent` itself, and a path outside
3167
+ * it, are not below it.
3168
+ *
3169
+ * @param {string} parent absolute
3170
+ * @param {string} child absolute
3171
+ * @returns {boolean}
3172
+ */
3173
+ function isBelow(parent, child) {
3174
+ const rel = path.relative(parent, child);
3175
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
3176
+ }
3177
+
3178
+ /**
3179
+ * D-CLAIM-FOLDER: the folder of the evidence file exists, or this run makes it. The Write
3180
+ * tool made the folders of a file in 3.11.0, so a first claim into a folder that is not
3181
+ * there yet worked. A run makes a folder only inside the working directory: the folder lies
3182
+ * below `io.cwd` by name, and the nearest folder that already exists resolves to `io.cwd` or
3183
+ * below it, so a link out of the working directory makes nothing. Each folder it makes goes
3184
+ * to `io.made`, deepest first, for removeMadeFolders. A path that holds a file where a folder
3185
+ * must be, or that the run may not make, is unusable. After each folder is made, its real path
3186
+ * must lie below the real working directory; if a link swapped in after the first check led the
3187
+ * folder out, the run is unusable and removeMadeFolders takes the folder away again. This check
3188
+ * narrows the window for such a swap; it does not close it, because the later open of the
3189
+ * evidence file resolves the path again.
3190
+ *
3191
+ * @param {Io} io
3192
+ * @param {string} dir absolute
3193
+ * @returns {boolean} whether the folder exists now
3194
+ */
3195
+ function ensureClaimFolder(io, dir) {
3196
+ if (isRealDirectory(dir)) return true;
3197
+ try {
3198
+ const root = path.resolve(io.cwd);
3199
+ if (fs.lstatSync(dir, { throwIfNoEntry: false }) !== undefined || !isBelow(root, dir)) return false;
3200
+ let near = dir;
3201
+ for (let i = 0; i < FOLDER_DEPTH_MAX && fs.lstatSync(near, { throwIfNoEntry: false }) === undefined; i++) {
3202
+ near = path.dirname(near);
3203
+ }
3204
+ const real = fs.realpathSync(near);
3205
+ const realRoot = fs.realpathSync(root);
3206
+ if (real !== realRoot && !isBelow(realRoot, real)) return false;
3207
+ let at = near;
3208
+ for (const part of path.relative(near, dir).split(path.sep)) {
3209
+ at = path.join(at, part);
3210
+ fs.mkdirSync(at);
3211
+ io.made.unshift(at);
3212
+ if (!isBelow(realRoot, fs.realpathSync(at))) return false;
3213
+ }
3214
+ return true;
3215
+ } catch (_) {
3216
+ return false;
3217
+ }
3218
+ }
3219
+
3220
+ /**
3221
+ * Take away the folders a `claim` run made. Each goes through rmdir, which refuses a folder
3222
+ * that holds anything, so a folder that holds the evidence file, or a file another process
3223
+ * put there, stays. It is never a recursive delete.
3224
+ *
3225
+ * @param {Io} io
3226
+ * @returns {void}
3227
+ */
3228
+ function removeMadeFolders(io) {
3229
+ for (const dir of io.made.splice(0)) {
3230
+ try { fs.rmdirSync(dir); } catch (_) { /* a folder that holds a file stays */ }
3231
+ }
3232
+ }
3233
+
3234
+ /**
3235
+ * The reason a `claim` run must not start, or null. Nothing is read or written before
3236
+ * this, but the folder of the evidence file, when it is missing: the HEAD is one 40-hex
3237
+ * SHA, each path is a path the commands build, the evidence file sits in a folder that
3238
+ * exists or that the run makes inside the working directory (D-CLAIM-FOLDER), and git says
3239
+ * clearly that neither file is tracked (D-TRACKED-RESULT).
3240
+ *
3241
+ * @param {Io} io
3242
+ * @param {Extract<ParsedArgs, { kind: 'claim' }>} args
3243
+ * @returns {string | null}
3244
+ */
3245
+ function claimRefusalReason(io, args) {
3246
+ if (!SHA40_RE.test(args.head)) return 'head not one sha';
3247
+ if (!EVIDENCE_PATH_RE.test(args.evidence) || !ensureClaimFolder(io, path.dirname(path.resolve(args.evidence)))) return 'evidence file unusable';
3248
+ if (args.plan !== null && !EVIDENCE_PATH_RE.test(args.plan)) return 'plan file unusable';
3249
+ if (trackedState(io, args.evidence) !== 'untracked') return 'evidence file tracked';
3250
+ if (args.plan !== null && trackedState(io, args.plan) !== 'untracked') return 'plan file unusable';
3251
+ return null;
3252
+ }
3253
+
3254
+ /**
3255
+ * Whether one line is exactly one claim to the claim parser of pr-evidence.cjs.
3256
+ *
3257
+ * @param {string} line
3258
+ * @returns {boolean}
3259
+ */
3260
+ function claimLineOk(line) {
3261
+ const r = PE.parseClaims(line);
3262
+ if (!r.ok) return false;
3263
+ const gates = r.value.gates;
3264
+ return r.value.malformed === 0 && r.value.tp.size + (gates.validate === null ? 0 : 1) + (gates.qa === null ? 0 : 1) === 1;
3265
+ }
3266
+
3267
+ /**
3268
+ * Assert a line before it is written: one claim to the claim parser. Every caller builds
3269
+ * the line from parts that are already checked; this is that precondition held at the sink.
3270
+ *
3271
+ * @param {string} line
3272
+ * @returns {string}
3273
+ */
3274
+ function claimLine(line) {
3275
+ if (!claimLineOk(line)) throw new Error('verify-evidence: unchecked claim line');
3276
+ return line;
3277
+ }
3278
+
3279
+ /**
3280
+ * The text to append: the lines, behind the `## Claims` heading when the file has no such
3281
+ * section (a blank line first, when the file holds anything). The caller appends only when
3282
+ * the section is the last of the file, so the lines go at its end.
3283
+ *
3284
+ * @param {string} text the evidence file as it is, '' when it is absent
3285
+ * @param {boolean} hasSection
3286
+ * @param {readonly string[]} lines
3287
+ * @returns {string}
3288
+ */
3289
+ function claimAppendText(text, hasSection, lines) {
3290
+ const body = lines.join('\n') + '\n';
3291
+ if (text === '') return PE.CLAIMS_HEADING + '\n' + body;
3292
+ const lead = text.endsWith('\n') ? '' : '\n';
3293
+ return hasSection ? lead + body : lead + '\n' + PE.CLAIMS_HEADING + '\n' + body;
3294
+ }
3295
+
3296
+ /**
3297
+ * D-CLAIM-INSERT: the whole text with the lines put at the end of a `## Claims` section that
3298
+ * another section follows: behind the last row of the section that is not blank, before the
3299
+ * blank rows that precede the next heading. A row is blank as the parser of pr-evidence.cjs
3300
+ * reads it, an empty row once its CR is gone. A text with no such section comes back as it was.
3301
+ *
3302
+ * @param {string} text
3303
+ * @param {readonly string[]} lines
3304
+ * @returns {string}
3305
+ */
3306
+ function claimInsertText(text, lines) {
3307
+ const rows = text.split('\n');
3308
+ /** @param {string} row @returns {string} */
3309
+ const bare = row => row.replace(/\r$/, '');
3310
+ const start = rows.findIndex(row => bare(row) === PE.CLAIMS_HEADING);
3311
+ if (start === -1) return text;
3312
+ let end = rows.findIndex((row, i) => i > start && (bare(row) === '##' || bare(row).startsWith('## ')));
3313
+ if (end === -1) end = rows.length;
3314
+ let last = end - 1;
3315
+ while (last > start && bare(rows[last]) === '') last--;
3316
+ rows.splice(last + 1, 0, ...lines);
3317
+ return rows.join('\n');
3318
+ }
3319
+
3320
+ /**
3321
+ * Whether `inserted` is `text` with the lines added at the end of its `## Claims` section and
3322
+ * nothing else changed, as the parser of pr-evidence.cjs reads both: the Claims section grew by
3323
+ * exactly the lines, its other sections are the same, and the result parses as an evidence file.
3324
+ * This is the check the replace is held to before it writes.
3325
+ *
3326
+ * @param {string} text
3327
+ * @param {string} inserted
3328
+ * @param {readonly string[]} lines
3329
+ * @returns {boolean}
3330
+ */
3331
+ function claimInserted(text, inserted, lines) {
3332
+ const before = PE.evidenceSections(text);
3333
+ const after = PE.evidenceSections(inserted);
3334
+ if (!before.ok || !after.ok || before.value.claims === null) return false;
3335
+ return after.value.claims === before.value.claims + '\n' + lines.join('\n')
3336
+ && after.value.testPlan === before.value.testPlan
3337
+ && after.value.exceptions === before.value.exceptions
3338
+ && parseEvidenceText(inserted) !== null;
3339
+ }
3340
+
3341
+ /**
3342
+ * The TP numbers the plan lists: the `## Test Plan` of the `--plan` file, else of the
3343
+ * evidence file. A plan with no TP line, or no plan section, lists none. Null when the
3344
+ * `--plan` file cannot serve as a plan.
3345
+ *
3346
+ * @param {Extract<ParsedArgs, { kind: 'claim' }>} args
3347
+ * @param {EvidenceInput} evidence
3348
+ * @returns {Set<number> | null}
3349
+ */
3350
+ function claimPlanIds(args, evidence) {
3351
+ let plan = evidence.plan;
3352
+ if (args.plan !== null) {
3353
+ const text = readTextFile(args.plan);
3354
+ const parsed = text === null ? null : PE.parsePlan(sectionOrWhole(text, 'testPlan'));
3355
+ if (parsed === null || (!parsed.ok && parsed.error.code !== 'empty')) return null;
3356
+ plan = parsed.ok ? parsed.value : { tps: [] };
3357
+ }
3358
+ return new Set((plan === null ? [] : plan.tps).map(tp => tp.id));
3359
+ }
3360
+
3361
+ /**
3362
+ * D-CLAIM-APPEND: `claim`. The checks of claimRefusalReason have passed (admitPaths).
3363
+ * The evidence file is read as `verify` reads it; a file that does not parse is refused, as
3364
+ * `verify` refuses it. Then every line is built and checked, and one write puts them all in.
3365
+ * That write is an append when the `## Claims` section is the last section or is absent. When
3366
+ * another section follows it, the write is a whole-file replace (D-CLAIM-INSERT) that adds the
3367
+ * lines at the end of the Claims section, held to claimInserted before it writes. One claim
3368
+ * run writes at a time, as the commands run it. A run that writes no line (every TP off the
3369
+ * plan) changes no file.
3370
+ *
3371
+ * @param {Io} io
3372
+ * @param {Extract<ParsedArgs, { kind: 'claim' }>} args
3373
+ * @returns {Outcome}
3374
+ */
3375
+ function runClaim(io, args) {
3376
+ const file = path.resolve(args.evidence);
3377
+ /** @type {fs.Stats | undefined} */
3378
+ let st;
3379
+ try {
3380
+ st = fs.lstatSync(file, { throwIfNoEntry: false });
3381
+ } catch (_) {
3382
+ return claimRefusal('evidence file unusable');
3383
+ }
3384
+ if (st !== undefined && !st.isFile()) return claimRefusal('evidence file unusable');
3385
+ const text = st === undefined ? '' : readTextFile(file);
3386
+ const evidence = text === null ? null : parseEvidenceText(text);
3387
+ if (text === null || evidence === null) return claimRefusal('evidence file unusable');
3388
+ const headings = text.split('\n').map(row => row.replace(/\r$/, '')).filter(row => row === '##' || row.startsWith('## '));
3389
+ const hasSection = headings.includes(PE.CLAIMS_HEADING);
3390
+ const sectionFollowed = hasSection && headings[headings.length - 1] !== PE.CLAIMS_HEADING;
3391
+ const listed = args.gate ? new Set() : claimPlanIds(args, evidence);
3392
+ if (listed === null) return claimRefusal('plan file unusable');
3393
+
3394
+ /** @type {string[]} */
3395
+ const lines = [];
3396
+ const tally = { PASS: 0, FAIL: 0, SKIP: 0 };
3397
+ if (args.gate) {
3398
+ lines.push(claimLine('- gate:validate PASS sha:' + args.head + ' by:validate'));
3399
+ tally.PASS++;
3400
+ }
3401
+ for (const tp of args.tps) {
3402
+ if (!listed.has(tp.id)) continue;
3403
+ const bare = '- TP-' + tp.id + ' ' + tp.outcome + ' sha:' + args.head + ' by:test';
3404
+ const withExit = tp.exit === null ? bare : bare + ' exit:' + tp.exit;
3405
+ lines.push(claimLine(claimLineOk(withExit) ? withExit : bare));
3406
+ tally[tp.outcome]++;
3407
+ }
3408
+ if (lines.length > 0) {
3409
+ let written;
3410
+ if (sectionFollowed) {
3411
+ const inserted = claimInsertText(text, lines);
3412
+ if (!claimInserted(text, inserted, lines)) return claimRefusal('evidence file unusable');
3413
+ written = replaceTextFile(file, inserted, st === undefined ? 0o600 : st.mode & 0o777);
3414
+ } else {
3415
+ written = appendTextFile(file, claimAppendText(text, hasSection, lines));
3416
+ }
3417
+ if (!written) return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
3418
+ }
3419
+ const skipped = args.gate ? 0 : args.tps.length - lines.length;
3420
+ return {
3421
+ code: EXIT_CODES.OK,
3422
+ stdout: 'CLAIM appended:' + lines.length + ' PASS:' + tally.PASS + ' FAIL:' + tally.FAIL + ' SKIP:' + tally.SKIP + ' skipped:' + skipped + '\n',
3423
+ };
3424
+ }
3425
+
2925
3426
  // ---------------------------------------------------------------------------
2926
3427
  // main — returns {code, stdout}; never calls process.exit
2927
3428
  // ---------------------------------------------------------------------------
@@ -2984,6 +3485,7 @@ function main(argv, deps) {
2984
3485
  deadline: now() + DEADLINE_MS,
2985
3486
  budget: { ghCalls: 0, runViews: 0, throttled: false },
2986
3487
  notes: new Set(),
3488
+ made: [],
2987
3489
  stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
2988
3490
  };
2989
3491
  const parsed = parseArgs(argv);
@@ -3005,6 +3507,7 @@ function main(argv, deps) {
3005
3507
  else if (args.kind === 'splice') outcome = runSplice(io, args);
3006
3508
  else if (args.kind === 'publish') outcome = runPublish(io, args, d);
3007
3509
  else if (args.kind === 'readiness') outcome = runReadiness(io, args, d);
3510
+ else if (args.kind === 'claim') outcome = runClaim(io, args);
3008
3511
  else outcome = runReadback(io, args);
3009
3512
  }
3010
3513
  } catch (err) {
@@ -3019,6 +3522,7 @@ function main(argv, deps) {
3019
3522
  if (args.out !== null) outcome = writeReadinessOut(io, args.out, args.pr, outcome, null, planSource(args));
3020
3523
  } else outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, label);
3021
3524
  }
3525
+ removeMadeFolders(io);
3022
3526
  if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
3023
3527
  return outcome;
3024
3528
  }
@@ -3038,12 +3542,26 @@ function errorLabel(err) {
3038
3542
  /**
3039
3543
  * D-VERIFY-STDOUT: settle whatever main() returned into what the boundary may
3040
3544
  * print. The code must be a known exit code and stdout must be empty or one of
3041
- * the closed shapes for that code; anything else prints nothing and exits 4.
3545
+ * the closed shapes for that code; anything else prints nothing and exits 4. A
3546
+ * `publish` run is the exception: it never settles to empty stdout, so what would
3547
+ * print nothing prints the evidence unavailable line with exit 2.
3042
3548
  *
3043
3549
  * @param {unknown} outcome
3550
+ * @param {boolean} [publish] true when the run is a `publish`
3044
3551
  * @returns {Outcome}
3045
3552
  */
3046
- function settleOutcome(outcome) {
3553
+ function settleOutcome(outcome, publish) {
3554
+ const settled = settleShape(outcome);
3555
+ return publish === true && settled.stdout === '' ? PUBLISH_UNAVAILABLE : settled;
3556
+ }
3557
+
3558
+ /**
3559
+ * The shape check of settleOutcome, for every subcommand.
3560
+ *
3561
+ * @param {unknown} outcome
3562
+ * @returns {Outcome}
3563
+ */
3564
+ function settleShape(outcome) {
3047
3565
  const o = /** @type {any} */ (outcome);
3048
3566
  const failed = { code: EXIT_CODES.REMOTE_FAILURE, stdout: '' };
3049
3567
  if (o === null || typeof o !== 'object' || typeof o.stdout !== 'string') return failed;
@@ -3073,6 +3591,14 @@ function settleOutcome(outcome) {
3073
3591
  const passing = readback.groups.outcome === 'ok';
3074
3592
  return o.code === (passing ? EXIT_CODES.OK : EXIT_CODES.OUTPUT_GATE_REFUSED) ? { code: o.code, stdout: o.stdout } : failed;
3075
3593
  }
3594
+ const claimRefused = CLAIM_REFUSAL_RE.exec(text);
3595
+ if (claimRefused !== null) return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
3596
+ const claimed = CLAIM_APPENDED_RE.exec(text);
3597
+ if (claimed !== null && claimed.groups !== undefined) {
3598
+ const g = claimed.groups;
3599
+ const sums = Number(g.n) === Number(g.pass) + Number(g.fail) + Number(g.skip);
3600
+ return o.code === EXIT_CODES.OK && sums && Number(g.n) <= CAPS.TPS && Number(g.skipped) <= CAPS.TPS ? pass : failed;
3601
+ }
3076
3602
  if (o.code !== EXIT_CODES.OK) return failed;
3077
3603
  if (PE.parseEvidenceLine(text).ok) return { code: o.code, stdout: o.stdout };
3078
3604
  const block = PE.parseBlock(text);
@@ -3094,7 +3620,7 @@ if (require.main === module) {
3094
3620
  process.stderr.write('verify-evidence: internal error (' + errorLabel(err) + ')\n');
3095
3621
  outcome = { code: EXIT_CODES.REMOTE_FAILURE, stdout: '' };
3096
3622
  }
3097
- const settled = settleOutcome(outcome);
3623
+ const settled = settleOutcome(outcome, isPublishRun(process.argv));
3098
3624
  if (settled.stdout !== '') process.stdout.write(settled.stdout);
3099
3625
  process.exitCode = settled.code;
3100
3626
  }