devflow-kit 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +1 -1
  3. package/dist/agents/code.md +6 -6
  4. package/dist/agents/fix.md +6 -6
  5. package/dist/agents/harden.md +7 -6
  6. package/dist/agents/refactor.md +6 -6
  7. package/dist/cli/commands/ambient.js +10 -2
  8. package/dist/cli/commands/compliance-prompts.js +9 -4
  9. package/dist/cli/commands/compliance.js +171 -24
  10. package/dist/cli/commands/curation.js +12 -7
  11. package/dist/cli/commands/gate-level-prompts.js +39 -25
  12. package/dist/cli/commands/gates.js +5 -7
  13. package/dist/cli/commands/init.js +142 -138
  14. package/dist/cli/commands/install-report.js +34 -0
  15. package/dist/cli/commands/legacy-sweep.js +64 -0
  16. package/dist/cli/commands/proxy.js +6 -1
  17. package/dist/cli/commands/rules.js +29 -12
  18. package/dist/cli/commands/skills.js +42 -9
  19. package/dist/cli/commands/tracker.js +9 -3
  20. package/dist/cli/commands/uninstall.js +277 -51
  21. package/dist/commands/bug-analysis.md +69 -120
  22. package/dist/commands/dynamic-build.md +23 -23
  23. package/dist/commands/implement.md +46 -32
  24. package/dist/commands/release.md +1 -1
  25. package/dist/commands/resolve.md +35 -31
  26. package/dist/commands/self-review.md +8 -7
  27. package/dist/compliance-off/agents/git.md +120 -0
  28. package/dist/compliance-off/agents/triage.md +180 -0
  29. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  30. package/dist/compliance-off/commands/code-review.md +520 -0
  31. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  32. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  33. package/dist/compliance-off/commands/implement.md +736 -0
  34. package/dist/compliance-off/commands/plan.md +671 -0
  35. package/dist/compliance-off/commands/release.md +309 -0
  36. package/dist/compliance-off/commands/resolve.md +742 -0
  37. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  38. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  39. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  40. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  41. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  42. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  43. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  44. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  45. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  46. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  47. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  48. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  49. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  50. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  51. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  52. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  53. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  54. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  55. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  56. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  57. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  58. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  59. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  60. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  61. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  62. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  63. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  64. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  65. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  66. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  67. package/dist/core/agent-models.js +19 -4
  68. package/dist/core/assets.js +126 -17
  69. package/dist/core/compliance-off-files.js +27 -0
  70. package/dist/core/curation-variants.js +460 -112
  71. package/dist/core/flags.js +2 -2
  72. package/dist/core/installed-variants.js +63 -0
  73. package/dist/core/manifest.js +48 -5
  74. package/dist/core/mds-variants.js +700 -3
  75. package/dist/core/orphan-sweep.js +11 -2
  76. package/dist/core/plugins.js +65 -8
  77. package/dist/curation-compliance-off/agents/triage.md +176 -0
  78. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  79. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  80. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  81. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  82. package/dist/curation-compliance-off/commands/implement.md +689 -0
  83. package/dist/curation-compliance-off/commands/plan.md +631 -0
  84. package/dist/curation-compliance-off/commands/release.md +279 -0
  85. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  86. package/dist/curation-off/agents/code.md +6 -6
  87. package/dist/curation-off/agents/fix.md +6 -6
  88. package/dist/curation-off/agents/harden.md +7 -6
  89. package/dist/curation-off/agents/refactor.md +6 -6
  90. package/dist/curation-off/commands/bug-analysis.md +69 -119
  91. package/dist/curation-off/commands/dynamic-build.md +23 -23
  92. package/dist/curation-off/commands/implement.md +46 -32
  93. package/dist/curation-off/commands/release.md +1 -1
  94. package/dist/curation-off/commands/resolve.md +35 -31
  95. package/dist/curation-off/commands/self-review.md +8 -7
  96. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  97. package/dist/targets/claude-code/behavior-install.js +23 -9
  98. package/dist/targets/claude-code/charter-render.js +374 -0
  99. package/dist/targets/claude-code/compliance-install.js +32 -12
  100. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  101. package/dist/targets/claude-code/curation-install.js +570 -100
  102. package/dist/targets/claude-code/installer.js +467 -62
  103. package/dist/targets/claude-code/language-stamp.js +6 -0
  104. package/dist/targets/claude-code/linked-folder.js +182 -0
  105. package/dist/targets/claude-code/reference-converge.js +118 -0
  106. package/dist/targets/claude-code/tracker-install.js +21 -8
  107. package/dist/targets/claude-code/tracker-servers.js +20 -6
  108. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  109. package/dist/variant-axes.json +109 -0
  110. package/package.json +1 -1
  111. package/src/assets/agents/_partials/_code.mds +6 -6
  112. package/src/assets/agents/evaluate.md +1 -1
  113. package/src/assets/agents/git.mds +32 -0
  114. package/src/assets/agents/harden.mds +7 -6
  115. package/src/assets/agents/test.md +21 -12
  116. package/src/assets/agents/triage.mds +2 -0
  117. package/src/assets/agents/validate.md +10 -10
  118. package/src/assets/commands/_partials/_compliance.mds +5 -3
  119. package/src/assets/commands/_partials/_engine.mds +9 -8
  120. package/src/assets/commands/_partials/_publication.mds +10 -1
  121. package/src/assets/commands/bug-analysis.mds +80 -126
  122. package/src/assets/commands/code-review.mds +43 -0
  123. package/src/assets/commands/dynamic-build.mds +109 -15
  124. package/src/assets/commands/dynamic-tickets.mds +13 -0
  125. package/src/assets/commands/implement.mds +145 -33
  126. package/src/assets/commands/plan.mds +19 -0
  127. package/src/assets/commands/release.mds +50 -1
  128. package/src/assets/commands/resolve.mds +217 -32
  129. package/src/assets/commands/self-review.mds +8 -7
  130. package/src/assets/mds/git/_ops.mds +23 -0
  131. package/src/assets/mds/git/_ops_steps.mds +17 -0
  132. package/src/assets/mds/git/_pr.mds +1 -1
  133. package/src/assets/mds/git/_references.mds +6 -0
  134. package/src/assets/mds/tracker/_common.mds +4 -0
  135. package/src/assets/mds/tracker/_github.mds +42 -0
  136. package/src/assets/mds/tracker/_jira.mds +43 -0
  137. package/src/assets/mds/tracker/_linear.mds +43 -0
  138. package/src/assets/rules/quality.md +2 -0
  139. package/src/assets/scripts/ci-wait.cjs +204 -126
  140. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  141. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  142. package/src/assets/scripts/hooks/preamble +18 -8
  143. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  144. package/src/assets/scripts/pr-evidence.cjs +41 -2
  145. package/src/assets/scripts/verify-evidence.cjs +408 -56
  146. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  147. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  148. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  149. package/src/assets/skills/agent-references/references/validate/passed-alone.md +7 -7
  150. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  151. package/src/assets/skills/git/SKILL.md +3 -3
  152. package/src/assets/skills/git/references/github-api.md +2 -139
  153. package/src/assets/skills/git/references/github-posting.md +158 -0
  154. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  155. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  156. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  157. package/src/assets/skills/testing/SKILL.md +2 -4
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Usage:
12
12
  // node verify-evidence.cjs check tp|block|exceptions|wave <file>
13
+ // node verify-evidence.cjs check wave --links-only <file>
13
14
  // node verify-evidence.cjs render --plan <file>
14
15
  // node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]
15
16
  // [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]
@@ -17,11 +18,15 @@
17
18
  // node verify-evidence.cjs readback --pr <n> --expect <file>
18
19
  // node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]
19
20
  // node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]
21
+ // [--evidence <file>] [--out <file>]
20
22
  //
21
23
  // stdout (D-VERIFY-STDOUT) is empty or exactly one of these shapes, each closed-vocabulary
22
24
  // (the render block's TP lines come from the caller's own plan file):
23
- // check nothing — the exit code is the answer; the diagnostic is on stderr
24
- // render the creation block: markers, `## Test Plan`, every TP unticked
25
+ // check nothing — the exit code is the answer; the diagnostic is on stderr.
26
+ // `check wave --links-only` also admits the links-only wave block (the
27
+ // `## Related Issues` heading and `Refs #<n>` or `Refs <KEY-N>` lines, no
28
+ // evidence section); plain `check wave` refuses that block as malformed
29
+ // render the creation block: markers, `## Test Plan`, every TP unticked
25
30
  // verify EVIDENCE pr:<n> head:<sha> total:<n> VERIFIED-CI:<n> ATTESTED-LOCAL:<n>
26
31
  // UNVERIFIED:<n> STALE:<n> FAILED:<n> INDETERMINATE:<n> stale:<ids|none>
27
32
  // exceptions:<kinds|none> approval:<yes|no|unchecked> key:<hex>
@@ -30,11 +35,34 @@
30
35
  // readback READBACK ok (exit 0) or READBACK mismatch (exit 5)
31
36
  // publish the `## PR Evidence` block — the heading, the EVIDENCE line, one
32
37
  // `**Body**: … · **Comment**: …` line — then one `TRACEABILITY: DEGRADED
33
- // (<reason>)` line per degraded part (exit 0), or the one line
34
- // `TRACEABILITY: DEGRADED (evidence unavailable)` (exit 2)
38
+ // (<reason>)` line per degraded part (exit 0), or exit 2 with one line:
39
+ // `TRACEABILITY: DEGRADED (evidence unavailable)` when it could not run,
40
+ // `TRACEABILITY: DEGRADED (compliance off)` when the machine manifest does not
41
+ // say compliance is enabled, or `TRACEABILITY: DEGRADED (compliance state
42
+ // unreadable)` when that manifest cannot be read. The last two come before
43
+ // any gh call, from the machine manifest alone
35
44
  // readiness the `## Merge Readiness` block (exit 0), or the same block with
36
45
  // `**Status**: DEGRADED (<reason>)` and one `TRACEABILITY: DEGRADED
37
- // (<reason>)` line after it (exit 2)
46
+ // (<reason>)` line after it (exit 2). `--evidence <file>` reads the test
47
+ // plan and the claims from a local evidence file, as `verify --evidence`
48
+ // does. `--out <file>` also writes the file `READINESS pr:<n>
49
+ // head:<40hex|none> status:<READY|NOT_READY|DEGRADED> source=<local|pr>`
50
+ // (one line; wrapped here), then that same block. `source=local` names
51
+ // the `--evidence` file as the plan's source and `source=pr` names the
52
+ // PR's own record. Stdout does not change, and the command makes no
53
+ // remote write.
54
+ // A file an earlier run left at the out path never outlives this run: it
55
+ // is removed before the first read, and a block that does not parse or a
56
+ // write that fails leaves no out file. The one exception is a path that
57
+ // git tracks (below), which is left as it is
58
+ // A git-tracked file counts as absent, like a tracked `.devflow/config.json`
59
+ // (D-TRACKED-RESULT). `verify`, `publish` and `readiness` ask git about an `--evidence`
60
+ // 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
62
+ // file be read or written. A tracked `--evidence` file, or one git cannot answer for, is
63
+ // dropped: the run goes on exactly as if `--evidence` was not given, and the stderr
64
+ // summary carries the note `evidence-ignored`. A tracked out path, or one git cannot
65
+ // answer for, is refused as a write failure (exit 3, empty stdout) and left untouched.
38
66
  // No byte of a PR body, a comment, a review or a gh/git answer is ever printed: the
39
67
  // boundary re-checks every stdout line against these shapes before writing it.
40
68
  //
@@ -59,7 +87,8 @@
59
87
  // `require.main === module` boundary is the only stdout write and the only
60
88
  // exitCode assignment
61
89
  // - every subprocess is spawned with an argv array (never a shell), stdin
62
- // ignored, a timeout and a maxBuffer; every loop and every API fan-out has a
90
+ // ignored and a maxBuffer, and every one but the local tracked-file check
91
+ // (D-TRACKED-RESULT) has a timeout; every loop and every API fan-out has a
63
92
  // fixed bound, and the whole run has a wall-clock deadline
64
93
  // - a revision reaches git only after SHA_RE, so none can start with `-`; `--`
65
94
  // ends the revision list of the one command that takes a pathspec (diff)
@@ -76,8 +105,8 @@ const childProcess = require('child_process');
76
105
 
77
106
  const PE = require(path.join(__dirname, 'pr-evidence.cjs'));
78
107
  // D-READINESS-CLASSIFIER: the CI classifier, the merge gate and the workflow-list probe
79
- // are ci-wait's own, so the two scripts cannot disagree; only ci-wait has the settle
80
- // window, since readiness makes one read. Its main is guarded by
108
+ // are ci-wait's own, so the two scripts cannot disagree; only ci-wait polls until its
109
+ // deadline, since readiness makes one read. Its main is guarded by
81
110
  // `require.main === module`: requiring it starts nothing.
82
111
  const CW = require(path.join(__dirname, 'ci-wait.cjs'));
83
112
 
@@ -158,6 +187,8 @@ const LINE_MAX_BUFFER = 4096;
158
187
  const COMMENT_READ_MAX_BUFFER = 8 * 65536;
159
188
  /** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
160
189
  const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
190
+ /** Where line 1 of the readiness out file says the test plan came from: the `--evidence` file, or the PR's own record. */
191
+ const READINESS_SOURCES = Object.freeze(['local', 'pr']);
161
192
  /** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
162
193
  const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
163
194
  const FETCH_MAX_BUFFER = 1024 * 1024;
@@ -194,6 +225,7 @@ const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
194
225
 
195
226
  const USAGE = [
196
227
  'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
228
+ ' node verify-evidence.cjs check wave --links-only <file>',
197
229
  ' node verify-evidence.cjs render --plan <file>',
198
230
  ' node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]',
199
231
  ' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
@@ -201,6 +233,7 @@ const USAGE = [
201
233
  ' node verify-evidence.cjs readback --pr <n> --expect <file>',
202
234
  ' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
203
235
  ' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
236
+ ' [--evidence <file>] [--out <file>]',
204
237
  ].join('\n');
205
238
 
206
239
  // ---------------------------------------------------------------------------
@@ -236,8 +269,9 @@ const USAGE = [
236
269
  * The one `gh pr view` answer, parsed. Lists are chronological, newest last.
237
270
  *
238
271
  * @typedef {{ exec?: ExecFn, cwd?: string, now?: () => number, stderr?: (text: string) => void,
239
- * formatLine?: (fields: object) => unknown }} MainDeps
240
- * Injected by tests only; `formatLine` reaches the output gate.
272
+ * formatLine?: (fields: object) => unknown, manifestPath?: string }} MainDeps
273
+ * Injected by tests only; `formatLine` reaches the output gate. `manifestPath` replaces
274
+ * the machine manifest that `publish` reads; production always reads ~/.devflow/manifest.json.
241
275
  *
242
276
  * @typedef {{ code: number, stdout: string }} Outcome
243
277
  */
@@ -248,14 +282,15 @@ const USAGE = [
248
282
 
249
283
  /**
250
284
  * @typedef {{ kind: 'usage' }
251
- * | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string }
285
+ * | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string, linksOnly: boolean }
252
286
  * | { kind: 'render', plan: string }
253
287
  * | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
254
288
  * blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
255
289
  * | { kind: 'splice', pr: number, state: string, block: string, out: string }
256
290
  * | { kind: 'readback', pr: number, expect: string }
257
291
  * | { kind: 'publish', pr: number, publication: string, evidence: string | null }
258
- * | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised' }} ParsedArgs
292
+ * | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised',
293
+ * evidence: string | null, out: string | null }} ParsedArgs
259
294
  */
260
295
 
261
296
  /** @type {ParsedArgs} */
@@ -316,10 +351,12 @@ function parseArgs(argv) {
316
351
  const sub = rest[0];
317
352
  const tail = rest.slice(1);
318
353
  if (sub === 'check') {
319
- if (tail.length !== 2 || !['tp', 'block', 'exceptions', 'wave'].includes(tail[0])) return USAGE_ARGS;
320
- const file = tail[1];
354
+ // `check wave --links-only <file>` is the one three-argument form: the links-only wave block is opt-in.
355
+ const linksOnly = tail.length === 3 && tail[0] === 'wave' && tail[1] === '--links-only';
356
+ if ((tail.length !== 2 && !linksOnly) || !['tp', 'block', 'exceptions', 'wave'].includes(tail[0])) return USAGE_ARGS;
357
+ const file = tail[tail.length - 1];
321
358
  if (typeof file !== 'string' || file === '' || file.startsWith('-') || file.length > MAX_ARG_CHARS) return USAGE_ARGS;
322
- return { kind: 'check', what: /** @type {'tp' | 'block' | 'exceptions' | 'wave'} */ (tail[0]), file };
359
+ return { kind: 'check', what: /** @type {'tp' | 'block' | 'exceptions' | 'wave'} */ (tail[0]), file, linksOnly };
323
360
  }
324
361
  if (sub === 'render') {
325
362
  const f = parseFlags(tail, { '--plan': 'value' });
@@ -371,13 +408,20 @@ function parseArgs(argv) {
371
408
  return { kind: 'publish', pr, publication, evidence };
372
409
  }
373
410
  if (sub === 'readiness') {
374
- const f = parseFlags(tail, { '--pr': 'value', '--require-non-author-approval': 'value' });
411
+ const f = parseFlags(tail, {
412
+ '--pr': 'value', '--require-non-author-approval': 'value', '--evidence': 'value', '--out': 'value',
413
+ });
375
414
  const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
376
415
  if (f === null || pr === null) return USAGE_ARGS;
377
416
  const needed = flagValue(f, '--require-non-author-approval');
378
417
  if (needed !== null && needed !== 'true' && needed !== 'false') return USAGE_ARGS;
418
+ const evidence = flagValue(f, '--evidence');
419
+ const out = flagValue(f, '--out');
420
+ // The out file is never the evidence file: two spellings of one path (`./a.md` and `a.md`)
421
+ // and two names of one file (a hard link or a symlink) are both refused before any read.
422
+ if (evidence !== null && out !== null && (path.resolve(evidence) === path.resolve(out) || sameFile(evidence, out))) return USAGE_ARGS;
379
423
  // An absent flag is the ladder's "unrecognised": it can never read READY.
380
- return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed };
424
+ return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed, evidence, out };
381
425
  }
382
426
  return USAGE_ARGS;
383
427
  }
@@ -386,6 +430,24 @@ function parseArgs(argv) {
386
430
  // Bounded file I/O
387
431
  // ---------------------------------------------------------------------------
388
432
 
433
+ /**
434
+ * Whether two paths name one file: the same device and inode, links followed. A path
435
+ * that does not exist, or cannot be inspected, names no file.
436
+ *
437
+ * @param {string} a
438
+ * @param {string} b
439
+ * @returns {boolean}
440
+ */
441
+ function sameFile(a, b) {
442
+ try {
443
+ const first = fs.statSync(a, { bigint: true, throwIfNoEntry: false });
444
+ const second = fs.statSync(b, { bigint: true, throwIfNoEntry: false });
445
+ return first !== undefined && second !== undefined && first.dev === second.dev && first.ino === second.ino;
446
+ } catch (_) {
447
+ return false;
448
+ }
449
+ }
450
+
389
451
  /**
390
452
  * Read a regular file of at most MAX_FILE_BYTES as strict UTF-8, or null. The
391
453
  * open adds O_NONBLOCK, so a FIFO put in a file's place cannot block, and the
@@ -427,19 +489,30 @@ function readTextFile(filePath) {
427
489
  /**
428
490
  * Write `text` to `filePath`, refusing anything that already exists and is not a
429
491
  * regular file (a directory, a FIFO, a symlink). Returns whether it was written.
492
+ * The lstat refuses first. The open adds O_NOFOLLOW, so a link put in the file's
493
+ * place after the lstat is refused, never followed, and O_NONBLOCK, so a FIFO put
494
+ * there cannot block; the fstat re-checks the opened object.
430
495
  *
431
496
  * @param {string} filePath
432
497
  * @param {string} text
433
498
  * @returns {boolean}
434
499
  */
435
500
  function writeTextFile(filePath, text) {
501
+ let fd;
436
502
  try {
437
503
  const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
438
504
  if (st !== undefined && !st.isFile()) return false;
439
- fs.writeFileSync(filePath, text, { encoding: 'utf8', mode: 0o600 });
505
+ const c = fs.constants;
506
+ fd = fs.openSync(filePath, c.O_WRONLY | c.O_CREAT | c.O_TRUNC | (c.O_NOFOLLOW || 0) | (c.O_NONBLOCK || 0), 0o600);
507
+ if (!fs.fstatSync(fd).isFile()) return false;
508
+ fs.writeFileSync(fd, text, 'utf8');
440
509
  return true;
441
510
  } catch (_) {
442
511
  return false;
512
+ } finally {
513
+ if (fd !== undefined) {
514
+ try { fs.closeSync(fd); } catch (_) { /* the write already decided */ }
515
+ }
443
516
  }
444
517
  }
445
518
 
@@ -505,12 +578,13 @@ function refusedCall(reason) {
505
578
  }
506
579
 
507
580
  /**
508
- * One bounded subprocess call, normalized. The argv array is a fresh copy.
581
+ * One bounded subprocess call, normalized. The argv array is a fresh copy. A null
582
+ * `timeout` sets no time limit on the call.
509
583
  *
510
584
  * @param {Io} io
511
585
  * @param {string} file
512
586
  * @param {readonly string[]} args
513
- * @param {number} timeout
587
+ * @param {number | null} timeout
514
588
  * @param {number} maxBuffer
515
589
  * @returns {CallResult}
516
590
  */
@@ -519,7 +593,7 @@ function runCall(io, file, args, timeout, maxBuffer) {
519
593
  cwd: io.cwd,
520
594
  env: io.env,
521
595
  stdio: ['ignore', 'pipe', 'pipe'],
522
- timeout,
596
+ ...(timeout === null ? {} : { timeout }),
523
597
  maxBuffer,
524
598
  windowsHide: true,
525
599
  shell: false,
@@ -598,18 +672,103 @@ function gh(io, args, maxBuffer) {
598
672
 
599
673
  /**
600
674
  * Every git call: `-c core.fsmonitor=false` first, so no repository-configured
601
- * hook runs (D-NO-FSMONITOR), and bounded by the deadline.
675
+ * hook runs (D-NO-FSMONITOR), and refused once the deadline has passed. A null
676
+ * `timeout` sets no time limit on the call itself.
602
677
  *
603
678
  * @param {Io} io
604
679
  * @param {readonly string[]} args
605
- * @param {number} timeout
680
+ * @param {number | null} timeout
606
681
  * @param {number} maxBuffer
607
682
  * @returns {CallResult}
608
683
  */
609
684
  function git(io, args, timeout, maxBuffer) {
610
685
  const left = remaining(io);
611
686
  if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
612
- return runCall(io, 'git', ['-c', 'core.fsmonitor=false', ...args], Math.min(timeout, left), maxBuffer);
687
+ return runCall(io, 'git', ['-c', 'core.fsmonitor=false', ...args], timeout === null ? null : Math.min(timeout, left), maxBuffer);
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Tracked files — D-TRACKED-RESULT
692
+ // ---------------------------------------------------------------------------
693
+
694
+ /** What git says, asked with LC_ALL=C, of a path that sits in no repository. */
695
+ const NOT_A_REPOSITORY_RE = /\bnot a git repository\b/;
696
+
697
+ /**
698
+ * D-TRACKED-RESULT: whether git tracks one absolute path, asked of the repository the
699
+ * path sits in (`-C` its directory, the name taken literally). `untracked` is a clear
700
+ * answer only: git exits 0 and lists nothing, or says the directory is in no
701
+ * repository. A listed path is `tracked`. A git that could not run or answered
702
+ * anything else, and a call refused past the deadline, is `unknown`. The call sets no
703
+ * time limit.
704
+ *
705
+ * @param {Io} io
706
+ * @param {string} abs
707
+ * @returns {'untracked' | 'tracked' | 'unknown'}
708
+ */
709
+ function trackedStateOf(io, abs) {
710
+ const probe = Object.assign({}, io, { env: Object.assign({}, io.env, { LC_ALL: 'C' }) });
711
+ const r = git(probe, ['-C', path.dirname(abs), '--literal-pathspecs', 'ls-files', '-z', '--', path.basename(abs)], null, LINE_MAX_BUFFER);
712
+ if (!answered(r)) return 'unknown';
713
+ if (r.status === 0) return r.stdout.length === 0 ? 'untracked' : 'tracked';
714
+ return r.status === 128 && NOT_A_REPOSITORY_RE.test(r.stderr) ? 'untracked' : 'unknown';
715
+ }
716
+
717
+ /**
718
+ * D-TRACKED-RESULT: a git-tracked file counts as absent, like a tracked
719
+ * `.devflow/config.json`. A path is `untracked` only when git says so for the path as
720
+ * spelled and for the file it resolves to (a link is judged by both names); one tracked
721
+ * name makes it `tracked`, and one name git could not answer for makes it `unknown`.
722
+ * It never throws.
723
+ *
724
+ * @param {Io} io
725
+ * @param {string} file
726
+ * @returns {'untracked' | 'tracked' | 'unknown'}
727
+ */
728
+ function trackedState(io, file) {
729
+ try {
730
+ const abs = path.resolve(file);
731
+ const names = [abs];
732
+ try {
733
+ const real = fs.realpathSync(abs);
734
+ if (real !== abs) names.push(real);
735
+ } catch (_) { /* a path that names nothing has no other name */ }
736
+ /** @type {'untracked' | 'tracked' | 'unknown'} */
737
+ let state = 'untracked';
738
+ for (const name of names) {
739
+ const one = trackedStateOf(io, name);
740
+ if (one === 'tracked') return 'tracked';
741
+ if (one === 'unknown') state = 'unknown';
742
+ }
743
+ return state;
744
+ } catch (_) {
745
+ return 'unknown';
746
+ }
747
+ }
748
+
749
+ /**
750
+ * D-TRACKED-RESULT: the paths a `verify`, `publish` or `readiness` run reads or writes
751
+ * as a result file. An out path (`--block-out`, `--comment-out`, `--stale-out`,
752
+ * `--out`) that is not clearly untracked is refused before any read or removal, with
753
+ * the write failure (exit 3): no file is written. An `--evidence` file that is not
754
+ * clearly untracked is dropped, so the run goes on exactly as if `--evidence` was not
755
+ * given; the stderr summary then carries the closed note `evidence-ignored`. Stdout
756
+ * keeps its grammar.
757
+ *
758
+ * @param {Io} io
759
+ * @param {ParsedArgs} args
760
+ * @returns {{ args: ParsedArgs } | { outcome: Outcome }}
761
+ */
762
+ function admitPaths(io, args) {
763
+ if (args.kind !== 'verify' && args.kind !== 'publish' && args.kind !== 'readiness') return { args };
764
+ const outs = args.kind === 'verify' ? [args.blockOut, args.commentOut, args.staleOut]
765
+ : args.kind === 'readiness' ? [args.out] : [];
766
+ if (outs.some(file => file !== null && trackedState(io, file) !== 'untracked')) {
767
+ return { outcome: refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written') };
768
+ }
769
+ if (args.evidence === null || trackedState(io, args.evidence) === 'untracked') return { args };
770
+ io.notes.add('evidence-ignored');
771
+ return { args: Object.assign({}, args, { evidence: null }) };
613
772
  }
614
773
 
615
774
  /**
@@ -1533,15 +1692,18 @@ function commentUpdate(plan, ev, mode, target) {
1533
1692
  }
1534
1693
 
1535
1694
  /**
1536
- * --stale-out: only STALE TPs whose text hash equals the trusted record's — text a
1537
- * trusted author already published, never unrecorded PR text (containment).
1695
+ * --stale-out: the STALE TPs. A plan from the PR body lists only those whose text hash
1696
+ * equals the trusted record's — text a trusted author already published, never
1697
+ * unrecorded PR text (containment). A plan from the `--evidence` file is the caller's
1698
+ * own local text, so every STALE TP of it is listed.
1538
1699
  *
1539
1700
  * @param {readonly TpInput[]} inputs
1540
1701
  * @param {readonly { state: string }[]} records
1702
+ * @param {boolean} fromFile whether the plan came from the evidence file
1541
1703
  * @returns {string}
1542
1704
  */
1543
- function staleOutText(inputs, records) {
1544
- const lines = inputs.filter((x, i) => records[i].state === 'STALE' && x.recorded).map(x => x.tp.line);
1705
+ function staleOutText(inputs, records, fromFile) {
1706
+ const lines = inputs.filter((x, i) => records[i].state === 'STALE' && (fromFile || x.recorded)).map(x => x.tp.line);
1545
1707
  return lines.length === 0 ? '' : ['## Test Plan', ...lines].join('\n') + '\n';
1546
1708
  }
1547
1709
 
@@ -1663,7 +1825,7 @@ function verifyPass(io, args, deps) {
1663
1825
  }, plan.tps.length);
1664
1826
  if (line === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1665
1827
 
1666
- if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records))) {
1828
+ if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records, fromFile))) {
1667
1829
  return refusedPass(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
1668
1830
  }
1669
1831
  return { outcome: { code: EXIT_CODES.OK, stdout: line + '\n' }, target };
@@ -1804,7 +1966,7 @@ function runCheck(io, args) {
1804
1966
  let result;
1805
1967
  if (args.what === 'tp') result = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
1806
1968
  else if (args.what === 'exceptions') result = PE.parseExceptions(sectionOrWhole(text, 'exceptions'));
1807
- else if (args.what === 'wave') result = PE.parseWaveBlock(text);
1969
+ else if (args.what === 'wave') result = PE.parseWaveBlock(text, { linksOnly: args.linksOnly });
1808
1970
  else {
1809
1971
  const b = PE.parseBlock(text);
1810
1972
  // R7 pastes only the creation form: TP lines, none ticked.
@@ -1855,6 +2017,15 @@ const REASON_RE = /^[a-z0-9][a-z0-9 -]{0,59}$/;
1855
2017
  /** What a `publish` result prints when it could not run at all (exit 2). */
1856
2018
  const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)';
1857
2019
 
2020
+ /** What a `publish` result prints when the machine manifest does not say compliance is enabled (exit 2). */
2021
+ const COMPLIANCE_OFF_LINE = 'TRACEABILITY: DEGRADED (compliance off)';
2022
+
2023
+ /** What a `publish` result prints when the machine manifest cannot be read (exit 2). */
2024
+ const COMPLIANCE_UNREADABLE_LINE = 'TRACEABILITY: DEGRADED (compliance state unreadable)';
2025
+
2026
+ /** Every line a `publish` that did not run may print. The boundary admits each one with exit 2 only. */
2027
+ const PUBLISH_REFUSAL_LINES = Object.freeze([EVIDENCE_UNAVAILABLE_LINE, COMPLIANCE_OFF_LINE, COMPLIANCE_UNREADABLE_LINE]);
2028
+
1858
2029
  /**
1859
2030
  * @typedef {{ status: string, reason: string | null }} Part
1860
2031
  * One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
@@ -2120,13 +2291,88 @@ function publishComment(io, pr, fields, files, target) {
2120
2291
  return post.ok ? okPart('POSTED') : writeFailedPart(post, 'gh pr comment failed');
2121
2292
  }
2122
2293
 
2294
+ /**
2295
+ * The machine manifest, ~/.devflow/manifest.json, or null when the home directory is
2296
+ * not absolute (a relative one would resolve against the repository). No environment
2297
+ * variable relocates it, and no repository file stands in for it.
2298
+ *
2299
+ * @returns {string | null}
2300
+ */
2301
+ function machineManifestPath() {
2302
+ let home;
2303
+ try {
2304
+ home = os.homedir();
2305
+ } catch (_) {
2306
+ return null;
2307
+ }
2308
+ return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.devflow', 'manifest.json') : null;
2309
+ }
2310
+
2311
+ /**
2312
+ * @param {unknown} value
2313
+ * @returns {value is Record<string, unknown>}
2314
+ */
2315
+ function isRecord(value) {
2316
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
2317
+ }
2318
+
2319
+ /**
2320
+ * The compliance switch of the machine manifest, read with this file's regular-file
2321
+ * rules (a link is followed, as every other reader of the machine manifest does). The
2322
+ * accepted shape is the one the CLI keeps, `features.compliance` as
2323
+ * `{ enabled: boolean, frameworks: string[] }`; a manifest from before compliance
2324
+ * existed has no such key and reads as off. A value in any other shape is `unreadable`,
2325
+ * where the CLI heals it to off: this guard only lets a publish through on a record it
2326
+ * can trust.
2327
+ *
2328
+ * @param {string | null} manifestPath
2329
+ * @returns {'enabled' | 'off' | 'unreadable'}
2330
+ */
2331
+ function machineComplianceState(manifestPath) {
2332
+ if (manifestPath === null) return 'unreadable';
2333
+ const text = readTextFile(manifestPath);
2334
+ if (text === null) return 'unreadable';
2335
+ /** @type {unknown} */
2336
+ let manifest;
2337
+ try {
2338
+ manifest = JSON.parse(text);
2339
+ } catch (_) {
2340
+ return 'unreadable';
2341
+ }
2342
+ if (!isRecord(manifest) || !isRecord(manifest.features)) return 'unreadable';
2343
+ const compliance = manifest.features.compliance;
2344
+ if (compliance === undefined) return 'off';
2345
+ if (!isRecord(compliance) || typeof compliance.enabled !== 'boolean') return 'unreadable';
2346
+ if (!Array.isArray(compliance.frameworks) || !compliance.frameworks.every(f => typeof f === 'string')) return 'unreadable';
2347
+ return compliance.enabled ? 'enabled' : 'off';
2348
+ }
2349
+
2350
+ /**
2351
+ * The publish guard: with compliance off, or its state unreadable, `publish` ends here,
2352
+ * before a work directory is made and before any gh call. It reads the machine manifest
2353
+ * under the user's home folder and nothing else, so no repository file can switch it on.
2354
+ * It ignores the devflow folder override. It returns its outcome and never throws: the
2355
+ * catch in main turns a throw into "evidence unavailable", which would hide the reason.
2356
+ *
2357
+ * @param {Io} io
2358
+ * @param {MainDeps} deps
2359
+ * @returns {Outcome | null} the refusal, or null when compliance is enabled
2360
+ */
2361
+ function publishRefusal(io, deps) {
2362
+ const state = machineComplianceState(typeof deps.manifestPath === 'string' ? deps.manifestPath : machineManifestPath());
2363
+ if (state === 'enabled') return null;
2364
+ io.stderr('verify-evidence: publish: '
2365
+ + (state === 'off' ? 'compliance is off on this machine' : 'the machine compliance state could not be read') + '\n');
2366
+ return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: (state === 'off' ? COMPLIANCE_OFF_LINE : COMPLIANCE_UNREADABLE_LINE) + '\n' };
2367
+ }
2368
+
2123
2369
  /**
2124
2370
  * The whole update-pr-evidence operation, in the order the operation always had:
2125
- * verify; only on `body:changed` scrub, splice, `gh pr edit`, read back; only on
2126
- * `posted:no` scrub, then edit the viewer's own evidence comment in place and read it
2127
- * back, or post a new one when there is none to edit or a newer evidence comment from
2128
- * another login follows it. The work files live in one private directory this call
2129
- * makes and removes.
2371
+ * the compliance guard; verify; only on `body:changed` scrub, splice, `gh pr edit`,
2372
+ * read back; only on `posted:no` scrub, then edit the viewer's own evidence comment in
2373
+ * place and read it back, or post a new one when there is none to edit or a newer
2374
+ * evidence comment from another login follows it. The work files live in one private
2375
+ * directory this call makes and removes.
2130
2376
  *
2131
2377
  * @param {Io} io
2132
2378
  * @param {Extract<ParsedArgs, { kind: 'publish' }>} args
@@ -2134,6 +2380,8 @@ function publishComment(io, pr, fields, files, target) {
2134
2380
  * @returns {Outcome}
2135
2381
  */
2136
2382
  function runPublish(io, args, deps) {
2383
+ const refused = publishRefusal(io, deps);
2384
+ if (refused !== null) return refused;
2137
2385
  /** @type {string} */
2138
2386
  let dir;
2139
2387
  try {
@@ -2466,16 +2714,18 @@ function bindCiHead(ci, viewHead, evidenceHead) {
2466
2714
 
2467
2715
  /**
2468
2716
  * The test-plan evidence at the head, by `verify --approval`: known only on exit 0
2469
- * with one EVIDENCE line for this PR (the old step 4's rule).
2717
+ * with one EVIDENCE line for this PR (the old step 4's rule). `evidence` is the local
2718
+ * evidence file `readiness --evidence` names, or null to read the PR's own record.
2470
2719
  *
2471
2720
  * @param {Io} io
2472
2721
  * @param {number} pr
2722
+ * @param {string | null} evidence
2473
2723
  * @param {MainDeps} deps
2474
2724
  * @returns {any | null} the parsed EVIDENCE fields, or null when the evidence is unknown
2475
2725
  */
2476
- function readEvidence(io, pr, deps) {
2726
+ function readEvidence(io, pr, evidence, deps) {
2477
2727
  const verified = runVerify(io, {
2478
- kind: 'verify', pr, publication: '', evidence: null, state: null,
2728
+ kind: 'verify', pr, publication: '', evidence, state: null,
2479
2729
  blockOut: null, commentOut: null, staleOut: null, approval: true,
2480
2730
  }, deps);
2481
2731
  if (verified.code !== EXIT_CODES.OK) return null;
@@ -2528,23 +2778,27 @@ function readinessDegraded(pr, reason, known) {
2528
2778
  * count it cannot establish, is DEGRADED; an unreadable CI status or unknown evidence
2529
2779
  * is an input to the ladder, which never lets either reach READY.
2530
2780
  *
2781
+ * @typedef {{ outcome: Outcome, head: string | null }} ReadinessPass
2782
+ * The answer, and the PR head its first read gave (null when that read gave none).
2783
+ *
2531
2784
  * @param {Io} io
2532
2785
  * @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
2533
2786
  * @param {MainDeps} deps
2534
- * @returns {Outcome}
2787
+ * @returns {ReadinessPass}
2535
2788
  */
2536
- function runReadiness(io, args, deps) {
2789
+ function readinessPass(io, args, deps) {
2537
2790
  const unreadApproval = approvalText(args.requireNonAuthor, null);
2538
2791
  const view = readPrView(io, args.pr);
2539
- if (!view.ok) return readinessDegraded(args.pr, view.reason, { approval: unreadApproval });
2792
+ if (!view.ok) return { outcome: readinessDegraded(args.pr, view.reason, { approval: unreadApproval }), head: null };
2793
+ const head = view.value.head;
2540
2794
  const reviewDecision = view.value.decision;
2541
2795
  const decisionText = decisionLabel(reviewDecision);
2542
2796
  const threads = readThreads(io, args.pr);
2543
2797
  if (!threads.ok) {
2544
- return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
2798
+ return { outcome: readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval }), head };
2545
2799
  }
2546
2800
  const ciRead = readCi(io, args.pr, view.value.mergeable);
2547
- const fields = readEvidence(io, args.pr, deps);
2801
+ const fields = readEvidence(io, args.pr, args.evidence, deps);
2548
2802
  const ci = bindCiHead(ciRead, view.value.head, fields === null ? null : fields.head);
2549
2803
  /** @type {EvidenceFacts | null} */
2550
2804
  const evidence = fields === null ? null : {
@@ -2577,7 +2831,95 @@ function runReadiness(io, args, deps) {
2577
2831
  + ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
2578
2832
  approval: approvalText(args.requireNonAuthor, evidence),
2579
2833
  };
2580
- return { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) };
2834
+ return { outcome: { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) }, head };
2835
+ }
2836
+
2837
+ /**
2838
+ * D-READINESS-OUT: `readiness --out <file>`. The file holds the READINESS line, then the
2839
+ * block exactly as it prints. The status in the line is the one the block's own parse
2840
+ * gives, so the line and the block cannot disagree. The head is the PR head the first
2841
+ * read gave, or `none`. The source is `local` when the plan was asked for from the
2842
+ * `--evidence` file and `pr` when it was asked for from the PR's own record; the line
2843
+ * ends with it as `source=<local|pr>`, and any other value is refused like a block that
2844
+ * does not parse. writeTextFile refuses a path that exists and is not a regular
2845
+ * file, a symlink included, so no link is followed. The command makes no gh write.
2846
+ *
2847
+ * One rule covers every outcome: a file from an earlier run never outlives this run.
2848
+ * runReadiness removes the earlier regular file before the first read; here, a block the
2849
+ * output gate would refuse is never written and is the gate's refusal, and a file that
2850
+ * cannot be written is the write refusal, with the regular file the write left removed.
2851
+ * Either way nothing is printed and no out file remains. A path that git tracks never
2852
+ * gets here: admitPaths refuses it first and leaves the file as it is.
2853
+ *
2854
+ * @param {Io} io
2855
+ * @param {string} file
2856
+ * @param {number} pr
2857
+ * @param {Outcome} outcome the answer readinessPass or readinessDegraded gave
2858
+ * @param {string | null} head
2859
+ * @param {string | undefined} source one of READINESS_SOURCES
2860
+ * @returns {Outcome}
2861
+ */
2862
+ function writeReadinessOut(io, file, pr, outcome, head, source) {
2863
+ const block = parseReadinessBlock(outcome.stdout);
2864
+ if (block === null || !READINESS_SOURCES.includes(/** @type {string} */ (source))) {
2865
+ removeOutFile(file);
2866
+ return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the readiness out file failed its grammar');
2867
+ }
2868
+ const line = [
2869
+ 'READINESS', 'pr:' + pr, 'head:' + (head === null ? 'none' : head), 'status:' + block.status, 'source=' + source,
2870
+ ].join(' ');
2871
+ if (!writeTextFile(file, line + '\n' + outcome.stdout)) {
2872
+ removeOutFile(file);
2873
+ return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
2874
+ }
2875
+ return outcome;
2876
+ }
2877
+
2878
+ /**
2879
+ * Remove the regular file an earlier run left at `file`. A path that names nothing, a
2880
+ * link, a FIFO or a directory is left as it is: writeTextFile creates the first and
2881
+ * refuses the rest. The lstat never follows a link. False when a regular file could not
2882
+ * be removed.
2883
+ *
2884
+ * @param {string} file
2885
+ * @returns {boolean}
2886
+ */
2887
+ function removeOutFile(file) {
2888
+ try {
2889
+ const st = fs.lstatSync(file, { throwIfNoEntry: false });
2890
+ if (st !== undefined && st.isFile()) fs.unlinkSync(file);
2891
+ return true;
2892
+ } catch (_) {
2893
+ return false;
2894
+ }
2895
+ }
2896
+
2897
+ /**
2898
+ * Where the run asked for the test plan: the local evidence file `--evidence` names, or the
2899
+ * PR's own record. It follows the flag alone, so every status of one invocation names the
2900
+ * same source.
2901
+ *
2902
+ * @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
2903
+ * @returns {'local' | 'pr'}
2904
+ */
2905
+ function planSource(args) {
2906
+ return args.evidence === null ? 'pr' : 'local';
2907
+ }
2908
+
2909
+ /**
2910
+ * @param {Io} io
2911
+ * @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
2912
+ * @param {MainDeps} deps
2913
+ * @returns {Outcome}
2914
+ */
2915
+ function runReadiness(io, args, deps) {
2916
+ // A file an earlier run left at the out path goes before the first read, so a run that
2917
+ // stops anywhere after this leaves no earlier answer behind.
2918
+ if (args.out !== null && !removeOutFile(args.out)) {
2919
+ return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
2920
+ }
2921
+ const pass = readinessPass(io, args, deps);
2922
+ return args.out === null ? pass.outcome : writeReadinessOut(io, args.out, args.pr, pass.outcome, pass.head, planSource(args));
2581
2923
  }
2582
2924
 
2583
2925
  // ---------------------------------------------------------------------------
@@ -2644,29 +2986,38 @@ function main(argv, deps) {
2644
2986
  notes: new Set(),
2645
2987
  stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
2646
2988
  };
2647
- const args = parseArgs(argv);
2648
- if (args.kind === 'usage') {
2989
+ const parsed = parseArgs(argv);
2990
+ if (parsed.kind === 'usage') {
2649
2991
  io.stderr(USAGE + '\n');
2650
2992
  return { code: EXIT_CODES.USAGE, stdout: '' };
2651
2993
  }
2994
+ let args = parsed;
2652
2995
  /** @type {Outcome} */
2653
2996
  let outcome;
2654
2997
  try {
2655
- if (args.kind === 'check') outcome = runCheck(io, args);
2656
- else if (args.kind === 'render') outcome = runRender(io, args);
2657
- else if (args.kind === 'verify') outcome = runVerify(io, args, d);
2658
- else if (args.kind === 'splice') outcome = runSplice(io, args);
2659
- else if (args.kind === 'publish') outcome = runPublish(io, args, d);
2660
- else if (args.kind === 'readiness') outcome = runReadiness(io, args, d);
2661
- else outcome = runReadback(io, args);
2998
+ const admitted = admitPaths(io, parsed);
2999
+ if ('outcome' in admitted) outcome = admitted.outcome;
3000
+ else {
3001
+ args = admitted.args;
3002
+ if (args.kind === 'check') outcome = runCheck(io, args);
3003
+ else if (args.kind === 'render') outcome = runRender(io, args);
3004
+ else if (args.kind === 'verify') outcome = runVerify(io, args, d);
3005
+ else if (args.kind === 'splice') outcome = runSplice(io, args);
3006
+ else if (args.kind === 'publish') outcome = runPublish(io, args, d);
3007
+ else if (args.kind === 'readiness') outcome = runReadiness(io, args, d);
3008
+ else outcome = runReadback(io, args);
3009
+ }
2662
3010
  } catch (err) {
2663
3011
  // An injected exec that throws, or a broken invariant (rev): no verdict. The two
2664
3012
  // scripted operations still answer in their own DEGRADED form, since a caller
2665
3013
  // prints whatever they print.
2666
3014
  const label = 'internal error (' + errorLabel(err) + ')';
2667
3015
  if (args.kind === 'publish') outcome = evidenceUnavailable(io, label);
2668
- else if (args.kind === 'readiness') outcome = readinessDegraded(args.pr, 'internal error', {});
2669
- else outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, label);
3016
+ else if (args.kind === 'readiness') {
3017
+ // The earlier out file is already gone (runReadiness), so the DEGRADED answer is the one file this run leaves.
3018
+ outcome = readinessDegraded(args.pr, 'internal error', {});
3019
+ if (args.out !== null) outcome = writeReadinessOut(io, args.out, args.pr, outcome, null, planSource(args));
3020
+ } else outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, label);
2670
3021
  }
2671
3022
  if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
2672
3023
  return outcome;
@@ -2705,7 +3056,7 @@ function settleOutcome(outcome) {
2705
3056
  if (o.stdout.startsWith('## PR Evidence\n')) {
2706
3057
  return o.code === EXIT_CODES.OK && parsePublishBlock(o.stdout) !== null ? pass : failed;
2707
3058
  }
2708
- if (o.stdout === EVIDENCE_UNAVAILABLE_LINE + '\n') return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
3059
+ if (PUBLISH_REFUSAL_LINES.some(line => o.stdout === line + '\n')) return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
2709
3060
  if (o.stdout.startsWith('## Merge Readiness\n')) {
2710
3061
  const block = parseReadinessBlock(o.stdout);
2711
3062
  if (block === null) return failed;
@@ -2766,6 +3117,7 @@ module.exports = Object.freeze({
2766
3117
  parsePublishBlock,
2767
3118
  renderReadinessBlock,
2768
3119
  parseReadinessBlock,
3120
+ writeReadinessOut,
2769
3121
  commentIdOf,
2770
3122
  editTarget,
2771
3123
  });