devflow-kit 3.8.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 (211) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +6 -6
  3. package/dist/agents/code.md +10 -10
  4. package/dist/agents/fix.md +7 -7
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/harden.md +7 -6
  7. package/dist/agents/refactor.md +6 -6
  8. package/dist/agents/skim.md +1 -1
  9. package/dist/agents/triage.md +7 -0
  10. package/dist/cli/commands/ambient.js +10 -2
  11. package/dist/cli/commands/compliance-prompts.js +9 -4
  12. package/dist/cli/commands/compliance.js +171 -24
  13. package/dist/cli/commands/curation.js +79 -23
  14. package/dist/cli/commands/gate-level-prompts.js +39 -23
  15. package/dist/cli/commands/gates.js +6 -7
  16. package/dist/cli/commands/init.js +150 -145
  17. package/dist/cli/commands/install-report.js +34 -0
  18. package/dist/cli/commands/legacy-sweep.js +64 -0
  19. package/dist/cli/commands/memory.js +59 -12
  20. package/dist/cli/commands/proxy.js +6 -1
  21. package/dist/cli/commands/rules.js +29 -12
  22. package/dist/cli/commands/skills.js +42 -9
  23. package/dist/cli/commands/tracker.js +9 -3
  24. package/dist/cli/commands/uninstall.js +354 -60
  25. package/dist/commands/bug-analysis.md +71 -122
  26. package/dist/commands/code-review.md +11 -11
  27. package/dist/commands/debug.md +2 -2
  28. package/dist/commands/dynamic-build.md +25 -25
  29. package/dist/commands/dynamic-plan.md +2 -2
  30. package/dist/commands/dynamic-profile.md +2 -2
  31. package/dist/commands/dynamic-tickets.md +2 -2
  32. package/dist/commands/explore.md +2 -2
  33. package/dist/commands/implement.md +48 -34
  34. package/dist/commands/plan.md +2 -2
  35. package/dist/commands/release.md +3 -3
  36. package/dist/commands/research.md +2 -2
  37. package/dist/commands/resolve.md +50 -46
  38. package/dist/commands/self-review.md +10 -9
  39. package/dist/compliance-off/agents/git.md +120 -0
  40. package/dist/compliance-off/agents/triage.md +180 -0
  41. package/dist/compliance-off/commands/bug-analysis.md +398 -0
  42. package/dist/compliance-off/commands/code-review.md +520 -0
  43. package/dist/compliance-off/commands/dynamic-build.md +1251 -0
  44. package/dist/compliance-off/commands/dynamic-tickets.md +676 -0
  45. package/dist/compliance-off/commands/implement.md +736 -0
  46. package/dist/compliance-off/commands/plan.md +671 -0
  47. package/dist/compliance-off/commands/release.md +309 -0
  48. package/dist/compliance-off/commands/resolve.md +742 -0
  49. package/dist/compliance-off/skills/git/references/decision-markers.md +17 -0
  50. package/dist/compliance-off/skills/git/references/learn-conventions.md +80 -0
  51. package/dist/compliance-off/skills/git/references/pr/check-ci-status.md +33 -0
  52. package/dist/compliance-off/skills/git/references/pr/check-merge-readiness.md +49 -0
  53. package/dist/compliance-off/skills/git/references/pr/fetch-review-threads.md +52 -0
  54. package/dist/compliance-off/skills/git/references/pr/validate-branch.md +47 -0
  55. package/dist/compliance-off/skills/git/references/tracker/_contract.md +33 -0
  56. package/dist/compliance-off/skills/git/references/tracker/_mcp.md +153 -0
  57. package/dist/compliance-off/skills/git/references/tracker/github/create-release.md +35 -0
  58. package/dist/compliance-off/skills/git/references/tracker/github/ensure-pr-ready.md +100 -0
  59. package/dist/compliance-off/skills/git/references/tracker/github/ensure-traceable-issue.md +74 -0
  60. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issue.md +69 -0
  61. package/dist/compliance-off/skills/git/references/tracker/github/fetch-issues-batch.md +65 -0
  62. package/dist/compliance-off/skills/git/references/tracker/github/gather-release-evidence.md +48 -0
  63. package/dist/compliance-off/skills/git/references/tracker/github/setup-task.md +77 -0
  64. package/dist/compliance-off/skills/git/references/tracker/jira/create-release.md +35 -0
  65. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-pr-ready.md +100 -0
  66. package/dist/compliance-off/skills/git/references/tracker/jira/ensure-traceable-issue.md +61 -0
  67. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issue.md +51 -0
  68. package/dist/compliance-off/skills/git/references/tracker/jira/fetch-issues-batch.md +63 -0
  69. package/dist/compliance-off/skills/git/references/tracker/jira/gather-release-evidence.md +47 -0
  70. package/dist/compliance-off/skills/git/references/tracker/jira/setup-task.md +82 -0
  71. package/dist/compliance-off/skills/git/references/tracker/linear/create-release.md +35 -0
  72. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-pr-ready.md +100 -0
  73. package/dist/compliance-off/skills/git/references/tracker/linear/ensure-traceable-issue.md +61 -0
  74. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issue.md +51 -0
  75. package/dist/compliance-off/skills/git/references/tracker/linear/fetch-issues-batch.md +63 -0
  76. package/dist/compliance-off/skills/git/references/tracker/linear/gather-release-evidence.md +47 -0
  77. package/dist/compliance-off/skills/git/references/tracker/linear/setup-task.md +83 -0
  78. package/dist/compliance-off/skills/git/references/trust-rule.md +7 -0
  79. package/dist/core/agent-models.js +19 -4
  80. package/dist/core/area-pause-notice.js +44 -0
  81. package/dist/core/assets.js +126 -17
  82. package/dist/core/compliance-off-files.js +27 -0
  83. package/dist/core/curation-move-skip.js +4 -5
  84. package/dist/core/curation-queue-cleanup.js +6 -5
  85. package/dist/core/curation-store.js +1 -0
  86. package/dist/core/curation-variants.js +460 -112
  87. package/dist/core/evidence-policy.js +10 -8
  88. package/dist/core/flags.js +2 -2
  89. package/dist/core/installed-variants.js +63 -0
  90. package/dist/core/manifest.js +48 -5
  91. package/dist/core/mds-variants.js +700 -3
  92. package/dist/core/orphan-sweep.js +11 -2
  93. package/dist/core/plugins.js +69 -12
  94. package/dist/core/tracked-areas.js +138 -14
  95. package/dist/curation-compliance-off/agents/triage.md +176 -0
  96. package/dist/curation-compliance-off/commands/bug-analysis.md +349 -0
  97. package/dist/curation-compliance-off/commands/code-review.md +481 -0
  98. package/dist/curation-compliance-off/commands/dynamic-build.md +1211 -0
  99. package/dist/curation-compliance-off/commands/dynamic-tickets.md +634 -0
  100. package/dist/curation-compliance-off/commands/implement.md +689 -0
  101. package/dist/curation-compliance-off/commands/plan.md +631 -0
  102. package/dist/curation-compliance-off/commands/release.md +279 -0
  103. package/dist/curation-compliance-off/commands/resolve.md +692 -0
  104. package/dist/curation-off/agents/code.md +10 -10
  105. package/dist/curation-off/agents/fix.md +7 -7
  106. package/dist/curation-off/agents/harden.md +7 -6
  107. package/dist/curation-off/agents/refactor.md +6 -6
  108. package/dist/curation-off/agents/triage.md +7 -0
  109. package/dist/curation-off/commands/bug-analysis.md +69 -119
  110. package/dist/curation-off/commands/code-review.md +11 -11
  111. package/dist/curation-off/commands/debug.md +2 -2
  112. package/dist/curation-off/commands/dynamic-build.md +25 -25
  113. package/dist/curation-off/commands/explore.md +2 -2
  114. package/dist/curation-off/commands/implement.md +48 -34
  115. package/dist/curation-off/commands/plan.md +2 -2
  116. package/dist/curation-off/commands/release.md +1 -1
  117. package/dist/curation-off/commands/resolve.md +50 -46
  118. package/dist/curation-off/commands/self-review.md +10 -9
  119. package/dist/hud/components/curation-counts.js +27 -0
  120. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  121. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  122. package/dist/skills/git/references/pr/resolve-review-threads.md +1 -1
  123. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  124. package/dist/skills/git/references/publication-gate.md +6 -7
  125. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  126. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  127. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  128. package/dist/targets/claude-code/behavior-install.js +23 -9
  129. package/dist/targets/claude-code/charter-render.js +374 -0
  130. package/dist/targets/claude-code/compliance-install.js +32 -12
  131. package/dist/targets/claude-code/compliance-off-files.js +245 -0
  132. package/dist/targets/claude-code/curation-install.js +570 -100
  133. package/dist/targets/claude-code/installer.js +467 -62
  134. package/dist/targets/claude-code/language-stamp.js +6 -0
  135. package/dist/targets/claude-code/linked-folder.js +182 -0
  136. package/dist/targets/claude-code/reference-converge.js +118 -0
  137. package/dist/targets/claude-code/tracker-install.js +21 -8
  138. package/dist/targets/claude-code/tracker-servers.js +20 -6
  139. package/dist/targets/claude-code/workflow-folder-install.js +231 -0
  140. package/dist/variant-axes.json +109 -0
  141. package/package.json +1 -1
  142. package/src/assets/agents/_partials/_code.mds +6 -6
  143. package/src/assets/agents/code.mds +4 -4
  144. package/src/assets/agents/evaluate.md +7 -13
  145. package/src/assets/agents/fix.mds +1 -1
  146. package/src/assets/agents/git.mds +34 -2
  147. package/src/assets/agents/harden.mds +7 -6
  148. package/src/assets/agents/skim.mds +1 -1
  149. package/src/assets/agents/test.md +21 -12
  150. package/src/assets/agents/triage.mds +9 -0
  151. package/src/assets/agents/validate.md +27 -34
  152. package/src/assets/commands/_partials/_compliance.mds +5 -3
  153. package/src/assets/commands/_partials/_decisions.mds +10 -8
  154. package/src/assets/commands/_partials/_engine.mds +9 -8
  155. package/src/assets/commands/_partials/_publication.mds +10 -1
  156. package/src/assets/commands/_partials/_settings.mds +2 -2
  157. package/src/assets/commands/bug-analysis.mds +80 -126
  158. package/src/assets/commands/code-review.mds +52 -9
  159. package/src/assets/commands/dynamic-build.mds +109 -15
  160. package/src/assets/commands/dynamic-tickets.mds +13 -0
  161. package/src/assets/commands/implement.mds +145 -33
  162. package/src/assets/commands/plan.mds +19 -0
  163. package/src/assets/commands/release.mds +50 -1
  164. package/src/assets/commands/resolve.mds +229 -44
  165. package/src/assets/commands/self-review.mds +8 -7
  166. package/src/assets/mds/git/_ops.mds +29 -1
  167. package/src/assets/mds/git/_ops_pr.mds +20 -10
  168. package/src/assets/mds/git/_ops_steps.mds +27 -0
  169. package/src/assets/mds/git/_pr.mds +15 -15
  170. package/src/assets/mds/git/_references.mds +12 -7
  171. package/src/assets/mds/tracker/_common.mds +4 -0
  172. package/src/assets/mds/tracker/_github.mds +43 -4
  173. package/src/assets/mds/tracker/_jira.mds +44 -4
  174. package/src/assets/mds/tracker/_linear.mds +44 -4
  175. package/src/assets/rules/quality.md +2 -0
  176. package/src/assets/scripts/ci-wait.cjs +493 -118
  177. package/src/assets/scripts/hooks/assets/implement-installed +1 -0
  178. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +13 -11
  179. package/src/assets/scripts/hooks/background-memory-update +79 -1
  180. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  181. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  182. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  183. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  184. package/src/assets/scripts/hooks/memory-permission +10 -2
  185. package/src/assets/scripts/hooks/memory-worker +11 -0
  186. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  187. package/src/assets/scripts/hooks/preamble +18 -8
  188. package/src/assets/scripts/hooks/queue-append +218 -1
  189. package/src/assets/scripts/hooks/session-start-context +89 -37
  190. package/src/assets/scripts/hooks/session-start-memory +39 -0
  191. package/src/assets/scripts/hooks/session-start-orchestrator +4 -2
  192. package/src/assets/scripts/pr-evidence.cjs +46 -4
  193. package/src/assets/scripts/resolve-settings.cjs +244 -30
  194. package/src/assets/scripts/verify-evidence.cjs +708 -124
  195. package/src/assets/skills/agent-references/SKILL.md +7 -1
  196. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  197. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +3 -3
  198. package/src/assets/skills/agent-references/references/commands/plan-levels.md +1 -1
  199. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  200. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +6 -2
  201. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  202. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  203. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  204. package/src/assets/skills/docs-framework/SKILL.md +5 -2
  205. package/src/assets/skills/git/SKILL.md +3 -3
  206. package/src/assets/skills/git/references/github-api.md +2 -139
  207. package/src/assets/skills/git/references/github-posting.md +158 -0
  208. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  209. package/src/assets/skills/review-methodology/references/patterns.md +1 -1
  210. package/src/assets/skills/review-methodology/references/report-template.md +1 -2
  211. 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)
@@ -75,9 +104,10 @@ const crypto = require('crypto');
75
104
  const childProcess = require('child_process');
76
105
 
77
106
  const PE = require(path.join(__dirname, 'pr-evidence.cjs'));
78
- // D-READINESS-CLASSIFIER: the CI classification is ci-wait's own, so the two scripts
79
- // cannot disagree. Its main is guarded by `require.main === module`: requiring it
80
- // starts nothing.
107
+ // D-READINESS-CLASSIFIER: the CI classifier, the merge gate and the workflow-list probe
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
110
+ // `require.main === module`: requiring it starts nothing.
81
111
  const CW = require(path.join(__dirname, 'ci-wait.cjs'));
82
112
 
83
113
  // ---------------------------------------------------------------------------
@@ -123,11 +153,13 @@ const DEADLINE_MS = 90000;
123
153
  /**
124
154
  * D-PUBLISH-BOUNDS: `publish` runs `verify` under the deadline above, then its writes
125
155
  * under a deadline of their own and a gh budget of their own. The writes are at most
126
- * seven calls: two body reads (the compare-and-swap), a body edit and one retry, one
127
- * read-back, a comment and one retry. A scrub is bounded by its own timeout, not by
128
- * the deadline, so one that starts just before the writes' deadline can end up to
129
- * REDACT_TIMEOUT_MS after it. The worst case is 90 s + 60 s + 20 s; the caller's
130
- * 180 s Bash timeout covers it with room for node's startup.
156
+ * eight calls: two body reads (the compare-and-swap), a body edit and one retry, one
157
+ * read-back, then the comment. A new comment is a post and one retry (seven in all).
158
+ * An edit of the viewer's own comment is the edit, one retry and one read of the
159
+ * comment (eight in all). A scrub is bounded by its own timeout, not by the deadline,
160
+ * so one that starts just before the writes' deadline can end up to REDACT_TIMEOUT_MS
161
+ * after it. The worst case is 90 s + 60 s + 20 s; the caller's 180 s Bash timeout
162
+ * covers it with room for node's startup.
131
163
  */
132
164
  const PUBLISH_DEADLINE_MS = 60000;
133
165
  const PUBLISH_GH_CALLS = 8;
@@ -151,9 +183,12 @@ const PR_VIEW_MAX_BUFFER = 64 * 1024 * 1024;
151
183
  const RUN_LIST_MAX_BUFFER = 1024 * 1024;
152
184
  const RUN_VIEW_MAX_BUFFER = 65536;
153
185
  const LINE_MAX_BUFFER = 4096;
186
+ /** A comment read back as a JSON string: 65,536 characters, each escaped to six, fit. */
187
+ const COMMENT_READ_MAX_BUFFER = 8 * 65536;
154
188
  /** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
155
189
  const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
156
- const READINESS_MAX_CHECKS = 1000;
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']);
157
192
  /** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
158
193
  const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
159
194
  const FETCH_MAX_BUFFER = 1024 * 1024;
@@ -173,6 +208,14 @@ const PR_FIELDS = 'body,comments,reviews,author,headRefOid,baseRefOid,isCrossRep
173
208
  const PR_RE = /^[1-9][0-9]{0,9}$/;
174
209
  const SHA40_RE = /^[0-9a-f]{40}$/;
175
210
 
211
+ /**
212
+ * A pull-request comment's url, anchored: the pull number, then the numeric id in the
213
+ * fragment. The host is not pinned (GitHub Enterprise Server hosts work). There is no
214
+ * `m` flag, so `$` is the end of the string and a trailing newline is refused.
215
+ */
216
+ const COMMENT_URL_RE = /^https:\/\/[^/\s?#]+\/[^/\s?#]+\/[^/\s?#]+\/pull\/([1-9][0-9]{0,9})#issuecomment-([1-9][0-9]{0,19})$/;
217
+ /** A comment id as a string of digits: up to 20, so an id beyond 2^53 stays exact. */
218
+ const COMMENT_ID_RE = /^[1-9][0-9]{0,19}$/;
176
219
  /** A review state that decides a login's position on the PR (D6). */
177
220
  const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
178
221
 
@@ -182,6 +225,7 @@ const READBACK_LINE_RE = /^READBACK (?<outcome>ok|mismatch)$/;
182
225
 
183
226
  const USAGE = [
184
227
  'Usage: node verify-evidence.cjs check tp|block|exceptions|wave <file>',
228
+ ' node verify-evidence.cjs check wave --links-only <file>',
185
229
  ' node verify-evidence.cjs render --plan <file>',
186
230
  ' node verify-evidence.cjs verify --pr <n> [--publication <mode>] [--evidence <file>]',
187
231
  ' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
@@ -189,6 +233,7 @@ const USAGE = [
189
233
  ' node verify-evidence.cjs readback --pr <n> --expect <file>',
190
234
  ' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
191
235
  ' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
236
+ ' [--evidence <file>] [--out <file>]',
192
237
  ].join('\n');
193
238
 
194
239
  // ---------------------------------------------------------------------------
@@ -215,7 +260,8 @@ const USAGE = [
215
260
  * summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
216
261
  * carries a budget of its own (`publish`'s writes).
217
262
  *
218
- * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean }} PrComment
263
+ * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean, commentId: string }} PrComment
264
+ * `commentId` is the comment's numeric id as digits, or '' when its url did not parse.
219
265
  * @typedef {{ login: string, association: string, state: string }} PrReview
220
266
  * @typedef {{ number: number, head: string, base: string, body: string, author: string,
221
267
  * isCrossRepository: boolean | undefined, comments: readonly PrComment[],
@@ -223,8 +269,9 @@ const USAGE = [
223
269
  * The one `gh pr view` answer, parsed. Lists are chronological, newest last.
224
270
  *
225
271
  * @typedef {{ exec?: ExecFn, cwd?: string, now?: () => number, stderr?: (text: string) => void,
226
- * formatLine?: (fields: object) => unknown }} MainDeps
227
- * 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.
228
275
  *
229
276
  * @typedef {{ code: number, stdout: string }} Outcome
230
277
  */
@@ -235,14 +282,15 @@ const USAGE = [
235
282
 
236
283
  /**
237
284
  * @typedef {{ kind: 'usage' }
238
- * | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string }
285
+ * | { kind: 'check', what: 'tp' | 'block' | 'exceptions' | 'wave', file: string, linksOnly: boolean }
239
286
  * | { kind: 'render', plan: string }
240
287
  * | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
241
288
  * blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
242
289
  * | { kind: 'splice', pr: number, state: string, block: string, out: string }
243
290
  * | { kind: 'readback', pr: number, expect: string }
244
291
  * | { kind: 'publish', pr: number, publication: string, evidence: string | null }
245
- * | { 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
246
294
  */
247
295
 
248
296
  /** @type {ParsedArgs} */
@@ -303,10 +351,12 @@ function parseArgs(argv) {
303
351
  const sub = rest[0];
304
352
  const tail = rest.slice(1);
305
353
  if (sub === 'check') {
306
- if (tail.length !== 2 || !['tp', 'block', 'exceptions', 'wave'].includes(tail[0])) return USAGE_ARGS;
307
- 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];
308
358
  if (typeof file !== 'string' || file === '' || file.startsWith('-') || file.length > MAX_ARG_CHARS) return USAGE_ARGS;
309
- 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 };
310
360
  }
311
361
  if (sub === 'render') {
312
362
  const f = parseFlags(tail, { '--plan': 'value' });
@@ -358,13 +408,20 @@ function parseArgs(argv) {
358
408
  return { kind: 'publish', pr, publication, evidence };
359
409
  }
360
410
  if (sub === 'readiness') {
361
- 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
+ });
362
414
  const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
363
415
  if (f === null || pr === null) return USAGE_ARGS;
364
416
  const needed = flagValue(f, '--require-non-author-approval');
365
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;
366
423
  // An absent flag is the ladder's "unrecognised": it can never read READY.
367
- return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed };
424
+ return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed, evidence, out };
368
425
  }
369
426
  return USAGE_ARGS;
370
427
  }
@@ -373,6 +430,24 @@ function parseArgs(argv) {
373
430
  // Bounded file I/O
374
431
  // ---------------------------------------------------------------------------
375
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
+
376
451
  /**
377
452
  * Read a regular file of at most MAX_FILE_BYTES as strict UTF-8, or null. The
378
453
  * open adds O_NONBLOCK, so a FIFO put in a file's place cannot block, and the
@@ -414,19 +489,30 @@ function readTextFile(filePath) {
414
489
  /**
415
490
  * Write `text` to `filePath`, refusing anything that already exists and is not a
416
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.
417
495
  *
418
496
  * @param {string} filePath
419
497
  * @param {string} text
420
498
  * @returns {boolean}
421
499
  */
422
500
  function writeTextFile(filePath, text) {
501
+ let fd;
423
502
  try {
424
503
  const st = fs.lstatSync(filePath, { throwIfNoEntry: false });
425
504
  if (st !== undefined && !st.isFile()) return false;
426
- 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');
427
509
  return true;
428
510
  } catch (_) {
429
511
  return false;
512
+ } finally {
513
+ if (fd !== undefined) {
514
+ try { fs.closeSync(fd); } catch (_) { /* the write already decided */ }
515
+ }
430
516
  }
431
517
  }
432
518
 
@@ -492,12 +578,13 @@ function refusedCall(reason) {
492
578
  }
493
579
 
494
580
  /**
495
- * 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.
496
583
  *
497
584
  * @param {Io} io
498
585
  * @param {string} file
499
586
  * @param {readonly string[]} args
500
- * @param {number} timeout
587
+ * @param {number | null} timeout
501
588
  * @param {number} maxBuffer
502
589
  * @returns {CallResult}
503
590
  */
@@ -506,7 +593,7 @@ function runCall(io, file, args, timeout, maxBuffer) {
506
593
  cwd: io.cwd,
507
594
  env: io.env,
508
595
  stdio: ['ignore', 'pipe', 'pipe'],
509
- timeout,
596
+ ...(timeout === null ? {} : { timeout }),
510
597
  maxBuffer,
511
598
  windowsHide: true,
512
599
  shell: false,
@@ -585,18 +672,103 @@ function gh(io, args, maxBuffer) {
585
672
 
586
673
  /**
587
674
  * Every git call: `-c core.fsmonitor=false` first, so no repository-configured
588
- * 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.
589
677
  *
590
678
  * @param {Io} io
591
679
  * @param {readonly string[]} args
592
- * @param {number} timeout
680
+ * @param {number | null} timeout
593
681
  * @param {number} maxBuffer
594
682
  * @returns {CallResult}
595
683
  */
596
684
  function git(io, args, timeout, maxBuffer) {
597
685
  const left = remaining(io);
598
686
  if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
599
- 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 }) };
600
772
  }
601
773
 
602
774
  /**
@@ -625,6 +797,19 @@ function loginArg(login) {
625
797
  return login;
626
798
  }
627
799
 
800
+ /**
801
+ * Assert a comment id before it reaches gh's argv (an API path segment):
802
+ * COMMENT_ID_RE, so no `/`, `..`, `?` or option can ride on PR data. commentIdOf
803
+ * only ever yields such ids; this is that precondition held at the sink.
804
+ *
805
+ * @param {string} id
806
+ * @returns {string}
807
+ */
808
+ function commentIdArg(id) {
809
+ if (typeof id !== 'string' || !COMMENT_ID_RE.test(id)) throw new Error('verify-evidence: unchecked comment id');
810
+ return id;
811
+ }
812
+
628
813
  // ---------------------------------------------------------------------------
629
814
  // The PR (one gh pr view)
630
815
  // ---------------------------------------------------------------------------
@@ -646,6 +831,21 @@ function loginOf(author) {
646
831
  return typeof login === 'string' && login.length <= 100 ? login : '';
647
832
  }
648
833
 
834
+ /**
835
+ * The numeric id of a PR comment, read from its url, as the digits themselves:
836
+ * never Number(), so an id beyond 2^53 stays exact. '' unless the url is a bounded
837
+ * string of the anchored pull-comment form AND names the PR that was read.
838
+ *
839
+ * @param {unknown} url
840
+ * @param {number} pr
841
+ * @returns {string}
842
+ */
843
+ function commentIdOf(url, pr) {
844
+ if (typeof url !== 'string' || url.length > MAX_URL_CHARS) return '';
845
+ const m = COMMENT_URL_RE.exec(url);
846
+ return m !== null && m[1] === String(pr) ? m[2] : '';
847
+ }
848
+
649
849
  /**
650
850
  * Parse the `gh pr view --json` answer into PrFacts, or null when it is not the
651
851
  * PR that was asked for or a field has the wrong shape. Malformed list entries
@@ -671,6 +871,7 @@ function parsePrJson(json, pr) {
671
871
  association: typeof x.authorAssociation === 'string' ? x.authorAssociation : '',
672
872
  body: typeof x.body === 'string' ? x.body : '',
673
873
  viewerDidAuthor: x.viewerDidAuthor === true,
874
+ commentId: commentIdOf(x.url, pr),
674
875
  });
675
876
  });
676
877
  const reviews = o.reviews.slice(-CAPS.REVIEWS).map(r => {
@@ -751,6 +952,38 @@ function markerComments(pr) {
751
952
  return pr.comments.filter(c => PE.MARKERS.EVIDENCE_RE.test(firstLine(c.body))).reverse();
752
953
  }
753
954
 
955
+ /**
956
+ * @typedef {{ id: string, marker: string, editable: boolean }} EditTarget
957
+ * `id` is the comment's id ('' when unreadable); `marker` is its first line;
958
+ * `editable` is false when a later comment from another login is itself an
959
+ * evidence marker.
960
+ */
961
+
962
+ /**
963
+ * The comment a publish would edit in place: the viewer's NEWEST comment whose first
964
+ * line starts with the evidence head prefix, whatever its key. "The viewer's" is the
965
+ * server-asserted `viewerDidAuthor` flag, so only an own comment is ever a target
966
+ * (never another login's comment, such as a maintainer's or a bot's). A malformed own
967
+ * comment (the prefix alone) is a valid target, so an edit repairs it.
968
+ *
969
+ * `editable` guards the trust order: verify trusts the newest evidence comment by
970
+ * list position, and an edit keeps its old position, so editing is safe only when
971
+ * no strict marker comment from another login comes after the target. A later
972
+ * prefix-only comment from another login is not a record and does not block. The
973
+ * strict trust functions (markerComments, resolveTrust, findTrustedRecord) stay as
974
+ * they are.
975
+ *
976
+ * @param {{ comments: readonly PrComment[] }} pr chronological, newest last
977
+ * @returns {EditTarget | null}
978
+ */
979
+ function editTarget(pr) {
980
+ const at = pr.comments.findLastIndex(c => c.viewerDidAuthor && firstLine(c.body).startsWith(PE.MARKERS.EVIDENCE_HEAD_PREFIX));
981
+ if (at === -1) return null;
982
+ const c = pr.comments[at];
983
+ const editable = !pr.comments.slice(at + 1).some(x => !x.viewerDidAuthor && PE.MARKERS.EVIDENCE_RE.test(firstLine(x.body)));
984
+ return Object.freeze({ id: c.commentId, marker: firstLine(c.body), editable });
985
+ }
986
+
754
987
  /**
755
988
  * D-VERIFY-APPROVAL (D6): each login's LATEST decisive review — APPROVED,
756
989
  * CHANGES_REQUESTED or DISMISSED — in chronological order.
@@ -1437,35 +1670,40 @@ function bodyUpdate(io, plan, ev, body) {
1437
1670
 
1438
1671
  /**
1439
1672
  * The comment (D-VERIFY-PUBLICATION) and whether it is already posted: only the
1440
- * viewer's own comment whose first line is this exact marker (head and key)
1441
- * counts, so a spoofed marker never suppresses a post. `off`, or nothing to
1442
- * record, is no comment (`n/a`). Null when it fails to render.
1673
+ * viewer's own NEWEST evidence comment (the edit target) whose first line is this
1674
+ * exact marker (head and key) counts, so a spoofed marker never suppresses a post
1675
+ * and an older exact match behind a newer own comment of another key reads `no`
1676
+ * (the newer one is what a publish edits). `off`, or nothing to record, is no
1677
+ * comment (`n/a`). Null when it fails to render.
1443
1678
  *
1444
1679
  * @param {{ tps: readonly any[] }} plan
1445
1680
  * @param {any} ev
1446
1681
  * @param {'full' | 'off' | 'stub'} mode
1447
- * @param {PrFacts} pr
1682
+ * @param {EditTarget | null} target
1448
1683
  * @returns {{ text: string, posted: 'yes' | 'no' | 'n/a' } | null}
1449
1684
  */
1450
- function commentUpdate(plan, ev, mode, pr) {
1685
+ function commentUpdate(plan, ev, mode, target) {
1451
1686
  if (mode === 'off' || (ev.records.length === 0 && ev.exceptions.length === 0)) return { text: '', posted: 'n/a' };
1452
1687
  const rendered = PE.render(plan, ev, mode);
1453
1688
  if (!rendered.ok) return null;
1454
1689
  const marker = firstLine(rendered.value.text);
1455
- const posted = pr.comments.some(c => c.viewerDidAuthor && firstLine(c.body) === marker) ? 'yes' : 'no';
1690
+ const posted = target !== null && target.marker === marker ? 'yes' : 'no';
1456
1691
  return { text: rendered.value.text, posted };
1457
1692
  }
1458
1693
 
1459
1694
  /**
1460
- * --stale-out: only STALE TPs whose text hash equals the trusted record's — text a
1461
- * 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.
1462
1699
  *
1463
1700
  * @param {readonly TpInput[]} inputs
1464
1701
  * @param {readonly { state: string }[]} records
1702
+ * @param {boolean} fromFile whether the plan came from the evidence file
1465
1703
  * @returns {string}
1466
1704
  */
1467
- function staleOutText(inputs, records) {
1468
- 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);
1469
1707
  return lines.length === 0 ? '' : ['## Test Plan', ...lines].join('\n') + '\n';
1470
1708
  }
1471
1709
 
@@ -1492,24 +1730,43 @@ function writeOutputs(args, body, blockText, commentText, staleText) {
1492
1730
  }
1493
1731
 
1494
1732
  /**
1733
+ * @typedef {{ outcome: Outcome, target: EditTarget | null }} VerifyPass
1734
+ * The verify result and the viewer's edit target from the same PR snapshot (null on
1735
+ * every refusal). The target stays in memory: it never reaches stdout, a flag or a file.
1736
+ */
1737
+
1738
+ /**
1739
+ * @param {Io} io
1740
+ * @param {number} code
1741
+ * @param {string} reason a fixed phrase from this file, never input bytes
1742
+ * @returns {VerifyPass}
1743
+ */
1744
+ function refusedPass(io, code, reason) {
1745
+ return { outcome: refuse(io, code, reason), target: null };
1746
+ }
1747
+
1748
+ /**
1749
+ * One verify pass: the Outcome the `verify` subcommand prints, and the edit target
1750
+ * the same snapshot gave. `posted` in the EVIDENCE line is taken from that target.
1751
+ *
1495
1752
  * @param {Io} io
1496
1753
  * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1497
1754
  * @param {MainDeps} deps
1498
- * @returns {Outcome}
1755
+ * @returns {VerifyPass}
1499
1756
  */
1500
- function runVerify(io, args, deps) {
1757
+ function verifyPass(io, args, deps) {
1501
1758
  // Inputs first: an unusable evidence file or state directory decides before any call.
1502
1759
  /** @type {EvidenceInput | null} */
1503
1760
  let evidence = null;
1504
1761
  if (args.evidence !== null) {
1505
1762
  evidence = loadEvidence(args.evidence);
1506
- if (evidence === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
1763
+ if (evidence === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
1507
1764
  if (evidence.malformed > 0) io.notes.add('malformed-claims');
1508
1765
  }
1509
- if (args.state !== null && !isRealDirectory(args.state)) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1766
+ if (args.state !== null && !isRealDirectory(args.state)) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1510
1767
 
1511
1768
  const pr = parsePrJson(prView(io, args.pr, PR_FIELDS), args.pr);
1512
- if (pr === null) return refuse(io, EXIT_CODES.REMOTE_FAILURE, 'the PR could not be read');
1769
+ if (pr === null) return refusedPass(io, EXIT_CODES.REMOTE_FAILURE, 'the PR could not be read');
1513
1770
 
1514
1771
  const trust = resolveTrust(io, pr, args.approval);
1515
1772
  const record = findTrustedRecord(pr, trust);
@@ -1520,12 +1777,12 @@ function runVerify(io, args, deps) {
1520
1777
  // trusted record's.
1521
1778
  const fromFile = evidence !== null && evidence.plan !== null;
1522
1779
  const plan = fromFile ? /** @type {any} */ (evidence).plan : bodyPlan(pr.body);
1523
- if (plan === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block cannot serve as the plan');
1780
+ if (plan === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block cannot serve as the plan');
1524
1781
  if (!fromFile && !coversRecord(plan, record)) {
1525
- return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block drops a TP the trusted record carries');
1782
+ return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block drops a TP the trusted record carries');
1526
1783
  }
1527
1784
  const inputs = tpInputs(plan, evidence, record, fromFile);
1528
- if (inputs === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'a TP line failed its grammar');
1785
+ if (inputs === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'a TP line failed its grammar');
1529
1786
 
1530
1787
  const repo = prepareRepo(io, pr);
1531
1788
  /** @type {RunMemo} */
@@ -1539,7 +1796,7 @@ function runVerify(io, args, deps) {
1539
1796
  : record.kind === 'record' ? record.exceptions : [];
1540
1797
  const key = PE.dedupeKey({ records, exceptions }, sha256);
1541
1798
  const tally = PE.tally(records.map(r => r.state));
1542
- if (!key.ok || !tally.ok) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the records failed their grammar');
1799
+ if (!key.ok || !tally.ok) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the records failed their grammar');
1543
1800
 
1544
1801
  const mode = publicationMode(args.publication);
1545
1802
  const ev = {
@@ -1550,8 +1807,9 @@ function runVerify(io, args, deps) {
1550
1807
  exceptions,
1551
1808
  };
1552
1809
  const body = bodyUpdate(io, plan, ev, pr.body);
1553
- const comment = commentUpdate(plan, ev, mode, pr);
1554
- if (body === null || comment === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block or the comment failed to render');
1810
+ const target = editTarget(pr);
1811
+ const comment = commentUpdate(plan, ev, mode, target);
1812
+ if (body === null || comment === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block or the comment failed to render');
1555
1813
 
1556
1814
  const line = gateEvidenceLine(deps, {
1557
1815
  pr: pr.number,
@@ -1565,12 +1823,22 @@ function runVerify(io, args, deps) {
1565
1823
  posted: comment.posted,
1566
1824
  body: body.changed ? 'changed' : 'same',
1567
1825
  }, plan.tps.length);
1568
- if (line === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1826
+ if (line === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1569
1827
 
1570
- if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records))) {
1571
- return refuse(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
1828
+ if (!writeOutputs(args, pr.body, body.blockText, comment.text, staleOutText(inputs, records, fromFile))) {
1829
+ return refusedPass(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
1572
1830
  }
1573
- return { code: EXIT_CODES.OK, stdout: line + '\n' };
1831
+ return { outcome: { code: EXIT_CODES.OK, stdout: line + '\n' }, target };
1832
+ }
1833
+
1834
+ /**
1835
+ * @param {Io} io
1836
+ * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1837
+ * @param {MainDeps} deps
1838
+ * @returns {Outcome}
1839
+ */
1840
+ function runVerify(io, args, deps) {
1841
+ return verifyPass(io, args, deps).outcome;
1574
1842
  }
1575
1843
 
1576
1844
  /**
@@ -1698,7 +1966,7 @@ function runCheck(io, args) {
1698
1966
  let result;
1699
1967
  if (args.what === 'tp') result = PE.parsePlan(sectionOrWhole(text, 'testPlan'));
1700
1968
  else if (args.what === 'exceptions') result = PE.parseExceptions(sectionOrWhole(text, 'exceptions'));
1701
- else if (args.what === 'wave') result = PE.parseWaveBlock(text);
1969
+ else if (args.what === 'wave') result = PE.parseWaveBlock(text, { linksOnly: args.linksOnly });
1702
1970
  else {
1703
1971
  const b = PE.parseBlock(text);
1704
1972
  // R7 pastes only the creation form: TP lines, none ticked.
@@ -1749,13 +2017,22 @@ const REASON_RE = /^[a-z0-9][a-z0-9 -]{0,59}$/;
1749
2017
  /** What a `publish` result prints when it could not run at all (exit 2). */
1750
2018
  const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)';
1751
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
+
1752
2029
  /**
1753
2030
  * @typedef {{ status: string, reason: string | null }} Part
1754
2031
  * One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
1755
2032
  */
1756
2033
 
1757
2034
  const BODY_STATUSES = Object.freeze(['EDITED', 'UNCHANGED', 'SKIPPED']);
1758
- const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF']);
2035
+ const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF', 'EDITED']);
1759
2036
 
1760
2037
  /**
1761
2038
  * @param {string} status
@@ -1900,6 +2177,18 @@ function ghWrite(io, args) {
1900
2177
  return { ok: r.ok, serverError: isServerError(r) };
1901
2178
  }
1902
2179
 
2180
+ /**
2181
+ * The DEGRADED part for a gh write that failed: the 5xx phrase after a server error,
2182
+ * else the phrase of the failed step. It runs no gh call.
2183
+ *
2184
+ * @param {{ ok: boolean, serverError: boolean }} result what ghWrite returned
2185
+ * @param {string} reason a fixed phrase from this file
2186
+ * @returns {Part}
2187
+ */
2188
+ function writeFailedPart(result, reason) {
2189
+ return degradedPart(result.serverError ? '5xx on update-pr-evidence' : reason);
2190
+ }
2191
+
1903
2192
  /**
1904
2193
  * The context for the writes: a gh budget and a deadline of their own, the same
1905
2194
  * notes. The throttle flag carries over, so a rate limit that verify hit stops the
@@ -1938,38 +2227,152 @@ function publishBody(io, pr, fields, files) {
1938
2227
  if (outcome === 'conflict') return okPart('SKIPPED');
1939
2228
  if (outcome !== 'ok' && outcome !== 'resplice') return degradedPart('splice ' + outcome);
1940
2229
  const edit = ghWrite(io, ['pr', 'edit', String(pr), '--body-file', files.body]);
1941
- if (!edit.ok) return degradedPart(edit.serverError ? '5xx on update-pr-evidence' : 'gh pr edit failed');
2230
+ if (!edit.ok) return writeFailedPart(edit, 'gh pr edit failed');
1942
2231
  const back = runReadback(io, { kind: 'readback', pr, expect: files.body });
1943
2232
  return back.code === EXIT_CODES.OK && back.stdout === 'READBACK ok\n' ? okPart('EDITED') : degradedPart('body read-back mismatch');
1944
2233
  }
1945
2234
 
1946
2235
  /**
1947
- * The comment half: the append-only evidence comment, scrubbed (D11) and re-measured
1948
- * after the scrub, posted once. An already-posted comment is never touched.
2236
+ * Whether the comment just edited now reads back as the scrubbed text: one GET of it
2237
+ * as a JSON string, compared after one trailing newline is normalised on each side.
2238
+ * The edit itself runs silent, so this is the only read of the comment. Any failure
2239
+ * to read or parse is "no". Neither the comment nor gh's stderr is printed.
2240
+ *
2241
+ * @param {Io} io the write-stage context
2242
+ * @param {string} id a comment id commentIdOf produced
2243
+ * @param {string} expected the scrubbed comment text (never the unscrubbed file)
2244
+ * @returns {boolean}
2245
+ */
2246
+ function commentReadsBack(io, id, expected) {
2247
+ const r = gh(io, ['api', 'repos/{owner}/{repo}/issues/comments/' + commentIdArg(id), '--jq', '.body | @json'], COMMENT_READ_MAX_BUFFER);
2248
+ if (!r.ok) return false;
2249
+ try {
2250
+ const body = JSON.parse(stripOneNewline(r.stdout.toString('utf8')));
2251
+ return typeof body === 'string' && stripOneNewline(body) === stripOneNewline(expected);
2252
+ } catch (_) {
2253
+ return false;
2254
+ }
2255
+ }
2256
+
2257
+ /**
2258
+ * The comment half: the evidence comment, scrubbed (D11) and re-measured after the
2259
+ * scrub. When the viewer's own evidence comment is a safe target (no strict marker
2260
+ * from another login follows it), that comment is edited in place (one PATCH of the
2261
+ * scrubbed file) and read back against it. Otherwise a new comment is posted once.
2262
+ * A comment that already carries this head and key is skipped. An own target with no
2263
+ * readable id is DEGRADED before the scrub, with no write. A failed edit or
2264
+ * read-back is a DEGRADED result and never falls back to a post. Nothing is deleted.
1949
2265
  *
1950
2266
  * @param {Io} io the write-stage context
1951
2267
  * @param {number} pr
1952
2268
  * @param {{ posted: string }} fields the EVIDENCE line's fields
1953
2269
  * @param {{ commentRaw: string, comment: string }} files
2270
+ * @param {EditTarget | null} target the viewer's newest evidence comment in the snapshot
2271
+ * verify read, or null when there is none
1954
2272
  * @returns {Part}
1955
2273
  */
1956
- function publishComment(io, pr, fields, files) {
2274
+ function publishComment(io, pr, fields, files, target) {
1957
2275
  if (fields.posted === 'yes') return okPart('SKIPPED');
1958
2276
  if (fields.posted === 'n/a') return okPart('OFF');
2277
+ const edit = target !== null && target.editable;
2278
+ if (edit && target.id === '') return degradedPart('comment id unreadable');
1959
2279
  if (!scrub(io, files.commentRaw, files.comment)) return degradedPart('redaction unavailable');
1960
2280
  const text = readTextFile(files.comment);
1961
2281
  if (text === null || text === '') return degradedPart('redaction unavailable');
1962
2282
  if (text.length > COMMENT_POST_MAX_CHARS) return degradedPart('comment over size cap');
2283
+ if (edit) {
2284
+ const patch = ghWrite(io, ['api', '--method', 'PATCH',
2285
+ 'repos/{owner}/{repo}/issues/comments/' + commentIdArg(target.id),
2286
+ '-F', 'body=@' + files.comment, '--silent']);
2287
+ if (!patch.ok) return writeFailedPart(patch, 'comment edit failed');
2288
+ return commentReadsBack(io, target.id, text) ? okPart('EDITED') : degradedPart('comment read-back mismatch');
2289
+ }
1963
2290
  const post = ghWrite(io, ['pr', 'comment', String(pr), '--body-file', files.comment]);
1964
- if (post.ok) return okPart('POSTED');
1965
- return degradedPart(post.serverError ? '5xx on update-pr-evidence' : 'gh pr comment failed');
2291
+ return post.ok ? okPart('POSTED') : writeFailedPart(post, 'gh pr comment failed');
2292
+ }
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' };
1966
2367
  }
1967
2368
 
1968
2369
  /**
1969
2370
  * The whole update-pr-evidence operation, in the order the operation always had:
1970
- * verify; only on `body:changed` scrub, splice, `gh pr edit`, read back; only on
1971
- * `posted:no` scrub and `gh pr comment`. The work files live in one private directory
1972
- * this call 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.
1973
2376
  *
1974
2377
  * @param {Io} io
1975
2378
  * @param {Extract<ParsedArgs, { kind: 'publish' }>} args
@@ -1977,6 +2380,8 @@ function publishComment(io, pr, fields, files) {
1977
2380
  * @returns {Outcome}
1978
2381
  */
1979
2382
  function runPublish(io, args, deps) {
2383
+ const refused = publishRefusal(io, deps);
2384
+ if (refused !== null) return refused;
1980
2385
  /** @type {string} */
1981
2386
  let dir;
1982
2387
  try {
@@ -2000,18 +2405,18 @@ function runPublish(io, args, deps) {
2000
2405
  commentRaw: file('comment.raw'),
2001
2406
  comment: file('comment'),
2002
2407
  };
2003
- const verified = runVerify(io, {
2408
+ const verified = verifyPass(io, {
2004
2409
  kind: 'verify', pr: args.pr, publication: args.publication, evidence: args.evidence, state,
2005
2410
  blockOut: files.notesRaw, commentOut: files.commentRaw, staleOut: null, approval: false,
2006
2411
  }, deps);
2007
- if (verified.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
2008
- const line = stripOneNewline(verified.stdout);
2412
+ if (verified.outcome.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
2413
+ const line = stripOneNewline(verified.outcome.stdout);
2009
2414
  const parsed = PE.parseEvidenceLine(line);
2010
2415
  if (!parsed.ok || parsed.value.pr !== args.pr) return evidenceUnavailable(io, 'verify answered something else');
2011
2416
 
2012
2417
  const stage = writeStageIo(io);
2013
2418
  const body = publishBody(stage, args.pr, parsed.value, files);
2014
- const comment = publishComment(stage, args.pr, parsed.value, files);
2419
+ const comment = publishComment(stage, args.pr, parsed.value, files, verified.target);
2015
2420
  return { code: EXIT_CODES.OK, stdout: renderPublishBlock(line, body, comment) };
2016
2421
  } finally {
2017
2422
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* the OS clears its temp directory */ }
@@ -2162,21 +2567,28 @@ function parseJsonOrNull(text) {
2162
2567
  }
2163
2568
 
2164
2569
  /**
2165
- * The PR's review decision. `gh pr view` answers `""` or null when no rule asks for a
2166
- * review; a string outside the three documented values reads UNRECOGNISED, and the
2167
- * ladder never lets that reach READY.
2570
+ * The PR's review decision, merge state and head, from one `gh pr view` read. The
2571
+ * decision answers `""` or null when no rule asks for a review; a string outside the
2572
+ * three documented values reads UNRECOGNISED, and the ladder never lets that reach READY.
2573
+ * `mergeable` is the string GitHub printed, or null when the key is absent or not a
2574
+ * string; `head` is the PR head when it is 40 lowercase hex, else null. Neither makes the
2575
+ * decision unreadable: the CI read treats them as unknown and never as a pass.
2168
2576
  *
2169
2577
  * @param {Io} io
2170
2578
  * @param {number} pr
2171
- * @returns {Read<string | null>}
2579
+ * @returns {Read<{ decision: string | null, mergeable: string | null, head: string | null }>}
2172
2580
  */
2173
- function readDecision(io, pr) {
2174
- const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision'], LINE_MAX_BUFFER);
2581
+ function readPrView(io, pr) {
2582
+ const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision,mergeable,headRefOid'], LINE_MAX_BUFFER);
2175
2583
  if (!r.ok) return { ok: false, reason: readFailure(r) };
2176
- const value = obj(parseJsonOrNull(r.stdout.toString('utf8'))).reviewDecision;
2177
- if (value === null || value === '') return { ok: true, value: null };
2584
+ const fields = obj(parseJsonOrNull(r.stdout.toString('utf8')));
2585
+ const value = fields.reviewDecision;
2586
+ const mergeable = typeof fields.mergeable === 'string' ? fields.mergeable : null;
2587
+ const head = typeof fields.headRefOid === 'string' && SHA40_RE.test(fields.headRefOid) ? fields.headRefOid : null;
2588
+ if (value === null || value === '') return { ok: true, value: { decision: null, mergeable, head } };
2178
2589
  if (typeof value !== 'string') return { ok: false, reason: 'review decision unreadable' };
2179
- return { ok: true, value: ['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED'].includes(value) ? value : 'UNRECOGNISED' };
2590
+ const decision = ['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED'].includes(value) ? value : 'UNRECOGNISED';
2591
+ return { ok: true, value: { decision, mergeable, head } };
2180
2592
  }
2181
2593
 
2182
2594
  /**
@@ -2215,49 +2627,105 @@ function readThreads(io, pr) {
2215
2627
  return { ok: true, value: { unresolved, approximate: total > THREADS_PAGE } };
2216
2628
  }
2217
2629
 
2630
+ /** CI that could not be established: the ladder reads it as `status unknown`, never READY. */
2631
+ const CI_INDETERMINATE = Object.freeze({ status: 'INDETERMINATE', failing: '-' });
2632
+
2218
2633
  /**
2219
- * D-READINESS-CLASSIFIER: the CI status, by check-ci-status's fetch (exit 0 or 8 is a
2220
- * result; "no checks reported" or `[]` is NO_CI; any other failure is INDETERMINATE)
2221
- * and ci-wait's classifier over `bucket`. Failing names are sanitized and bounded by
2222
- * ci-wait's own formatter.
2634
+ * D-READINESS-WORKFLOWS: whether the repository has an active workflow file, by ci-wait's
2635
+ * one read-only call and parser. A call that fails or is refused, and a reply the parser
2636
+ * cannot read, give null: not knowing, never "no workflow file".
2637
+ *
2638
+ * @param {Io} io
2639
+ * @returns {boolean | null}
2640
+ */
2641
+ function probeWorkflows(io) {
2642
+ const r = gh(io, CW.WORKFLOWS_ARGS, READINESS_MAX_BUFFER);
2643
+ return r.ok ? CW.parseActiveWorkflows(r.stdout.toString('utf8')) : null;
2644
+ }
2645
+
2646
+ /**
2647
+ * An empty check list: ci-wait's merge gate decides (D-CI-WORKFLOWS). The workflow list
2648
+ * is asked only when the gate asks for it, so a PR that cannot be NO_CI costs no call. A
2649
+ * gate answer other than keep is INDETERMINATE.
2650
+ *
2651
+ * @param {Io} io
2652
+ * @param {string | null} mergeable
2653
+ * @returns {{ status: string, failing: string }}
2654
+ */
2655
+ function readEmptyCi(io, mergeable) {
2656
+ const first = CW.mergeGate('NO_CI', mergeable);
2657
+ const gate = first === 'probe' ? CW.mergeGate('NO_CI', mergeable, probeWorkflows(io)) : first;
2658
+ return gate === 'keep' ? { status: 'NO_CI', failing: '-' } : CI_INDETERMINATE;
2659
+ }
2660
+
2661
+ /**
2662
+ * D-READINESS-CLASSIFIER: the CI status, read once. The checks come from `gh pr checks`
2663
+ * over name,state,bucket,workflow, with check-ci-status's exit rule (exit 0 or 8 is a
2664
+ * result; any other failure is INDETERMINATE). The rows come from ci-wait's parser, and the
2665
+ * status from ci-wait's classifier (D-CI-WORKFLOW-RULE) and merge gate (D-CI-MERGE-STATE,
2666
+ * D-CI-WORKFLOWS), where the rules are written, so the two scripts cannot disagree. A
2667
+ * conflicting PR reads INDETERMINATE before any checks call, and "no checks reported" or
2668
+ * `[]` goes to readEmptyCi. An unknown merge state, a failed workflow-list call and an
2669
+ * unreadable reply are INDETERMINATE, never DEGRADED. Failing names are sanitized and
2670
+ * bounded by ci-wait's own formatter. PASSING does not prove that the test workflow ran by
2671
+ * name.
2223
2672
  *
2224
2673
  * @param {Io} io
2225
2674
  * @param {number} pr
2675
+ * @param {string | null} mergeable GitHub's merge state of the PR, from readPrView
2226
2676
  * @returns {{ status: string, failing: string }}
2227
2677
  */
2228
- function readCi(io, pr) {
2229
- const r = gh(io, ['pr', 'checks', String(pr), '--json', 'name,state,bucket'], READINESS_MAX_BUFFER);
2230
- const indeterminate = { status: 'INDETERMINATE', failing: '-' };
2231
- if (!answered(r)) return indeterminate;
2678
+ function readCi(io, pr, mergeable) {
2679
+ if (CW.isConflicting(mergeable)) return CI_INDETERMINATE;
2680
+ const r = gh(io, ['pr', 'checks', String(pr), '--json', 'name,state,bucket,workflow'], READINESS_MAX_BUFFER);
2681
+ if (!answered(r)) return CI_INDETERMINATE;
2232
2682
  if (r.status !== 0 && r.status !== 8) {
2233
2683
  if (/Could not resolve to a PullRequest/.test(r.stderr)) return { status: 'NO_PR', failing: '-' };
2234
- return /no checks reported/i.test(r.stderr) ? { status: 'NO_CI', failing: '-' } : indeterminate;
2235
- }
2236
- const rows = parseJsonOrNull(r.stdout.toString('utf8'));
2237
- if (!Array.isArray(rows) || rows.length > READINESS_MAX_CHECKS) return indeterminate;
2238
- if (rows.length === 0) return { status: 'NO_CI', failing: '-' };
2239
- const checks = [];
2240
- for (const row of rows) {
2241
- const x = obj(row);
2242
- if (typeof x.name !== 'string' || typeof x.bucket !== 'string') return indeterminate;
2243
- checks.push({ name: x.name, bucket: x.bucket });
2684
+ return /no checks reported/i.test(r.stderr) ? readEmptyCi(io, mergeable) : CI_INDETERMINATE;
2244
2685
  }
2686
+ const checks = CW.parseChecks(r.stdout.toString('utf8'));
2687
+ if (checks === null) return CI_INDETERMINATE;
2688
+ if (checks.length === 0) return readEmptyCi(io, mergeable);
2245
2689
  const c = CW.classifyChecks(checks);
2690
+ if (CW.mergeGate(c.status, mergeable, null) !== 'keep') return CI_INDETERMINATE;
2246
2691
  return { status: c.status, failing: CW.formatNames(c.failing) };
2247
2692
  }
2248
2693
 
2694
+ /**
2695
+ * D-READINESS-HEAD: CI counts for one head. `gh pr checks` answers for whatever head the
2696
+ * PR has when it is called, and the evidence is read after it, so a push in between would
2697
+ * pair a CI result with evidence of another head. PASSING and NO_CI stand only when the
2698
+ * head read with the review decision, before the checks call, and the head the evidence
2699
+ * was read at are both 40 lowercase hex and equal; otherwise CI is INDETERMINATE. FAILING,
2700
+ * PENDING and the rest stay: they hold the ladder back on their own.
2701
+ *
2702
+ * @param {{ status: string, failing: string }} ci
2703
+ * @param {unknown} viewHead the head read before the checks call, or null
2704
+ * @param {unknown} evidenceHead the head the evidence was read at, or null when it is unknown
2705
+ * @returns {{ status: string, failing: string }}
2706
+ */
2707
+ function bindCiHead(ci, viewHead, evidenceHead) {
2708
+ if (ci.status !== 'PASSING' && ci.status !== 'NO_CI') return ci;
2709
+ /** @param {unknown} head */
2710
+ const isHead = head => typeof head === 'string' && SHA40_RE.test(head);
2711
+ const bound = isHead(viewHead) && isHead(evidenceHead) && viewHead === evidenceHead;
2712
+ return bound ? ci : CI_INDETERMINATE;
2713
+ }
2714
+
2249
2715
  /**
2250
2716
  * The test-plan evidence at the head, by `verify --approval`: known only on exit 0
2251
- * 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.
2252
2719
  *
2253
2720
  * @param {Io} io
2254
2721
  * @param {number} pr
2722
+ * @param {string | null} evidence
2255
2723
  * @param {MainDeps} deps
2256
2724
  * @returns {any | null} the parsed EVIDENCE fields, or null when the evidence is unknown
2257
2725
  */
2258
- function readEvidence(io, pr, deps) {
2726
+ function readEvidence(io, pr, evidence, deps) {
2259
2727
  const verified = runVerify(io, {
2260
- kind: 'verify', pr, publication: '', evidence: null, state: null,
2728
+ kind: 'verify', pr, publication: '', evidence, state: null,
2261
2729
  blockOut: null, commentOut: null, staleOut: null, approval: true,
2262
2730
  }, deps);
2263
2731
  if (verified.code !== EXIT_CODES.OK) return null;
@@ -2275,6 +2743,15 @@ function approvalText(require, evidence) {
2275
2743
  return evidence !== null && evidence.approval === 'yes' ? 'yes' : 'no';
2276
2744
  }
2277
2745
 
2746
+ /**
2747
+ * @param {string | null} decision the review decision from readPrView
2748
+ * @returns {string} the word the Details line prints for it
2749
+ */
2750
+ function decisionLabel(decision) {
2751
+ if (decision === null) return 'none';
2752
+ return decision === 'UNRECOGNISED' ? 'unrecognised' : decision;
2753
+ }
2754
+
2278
2755
  /**
2279
2756
  * A readiness answer that could not be reached: a DEGRADED block (exit 2) whose Details
2280
2757
  * say `unavailable` for what was never read.
@@ -2301,22 +2778,28 @@ function readinessDegraded(pr, reason, known) {
2301
2778
  * count it cannot establish, is DEGRADED; an unreadable CI status or unknown evidence
2302
2779
  * is an input to the ladder, which never lets either reach READY.
2303
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
+ *
2304
2784
  * @param {Io} io
2305
2785
  * @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
2306
2786
  * @param {MainDeps} deps
2307
- * @returns {Outcome}
2787
+ * @returns {ReadinessPass}
2308
2788
  */
2309
- function runReadiness(io, args, deps) {
2789
+ function readinessPass(io, args, deps) {
2310
2790
  const unreadApproval = approvalText(args.requireNonAuthor, null);
2311
- const decision = readDecision(io, args.pr);
2312
- if (!decision.ok) return readinessDegraded(args.pr, decision.reason, { approval: unreadApproval });
2313
- const decisionText = decision.value === null ? 'none' : decision.value === 'UNRECOGNISED' ? 'unrecognised' : decision.value;
2791
+ const view = readPrView(io, args.pr);
2792
+ if (!view.ok) return { outcome: readinessDegraded(args.pr, view.reason, { approval: unreadApproval }), head: null };
2793
+ const head = view.value.head;
2794
+ const reviewDecision = view.value.decision;
2795
+ const decisionText = decisionLabel(reviewDecision);
2314
2796
  const threads = readThreads(io, args.pr);
2315
2797
  if (!threads.ok) {
2316
- return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
2798
+ return { outcome: readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval }), head };
2317
2799
  }
2318
- const ci = readCi(io, args.pr);
2319
- const fields = readEvidence(io, args.pr, deps);
2800
+ const ciRead = readCi(io, args.pr, view.value.mergeable);
2801
+ const fields = readEvidence(io, args.pr, args.evidence, deps);
2802
+ const ci = bindCiHead(ciRead, view.value.head, fields === null ? null : fields.head);
2320
2803
  /** @type {EvidenceFacts | null} */
2321
2804
  const evidence = fields === null ? null : {
2322
2805
  total: fields.total,
@@ -2328,7 +2811,7 @@ function runReadiness(io, args, deps) {
2328
2811
  const verdict = readinessVerdict({
2329
2812
  unresolved: threads.value.unresolved,
2330
2813
  approximate: threads.value.approximate,
2331
- decision: decision.value,
2814
+ decision: reviewDecision,
2332
2815
  ci: ci.status,
2333
2816
  failing: ci.failing,
2334
2817
  evidence,
@@ -2348,7 +2831,95 @@ function runReadiness(io, args, deps) {
2348
2831
  + ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
2349
2832
  approval: approvalText(args.requireNonAuthor, evidence),
2350
2833
  };
2351
- 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));
2352
2923
  }
2353
2924
 
2354
2925
  // ---------------------------------------------------------------------------
@@ -2415,29 +2986,38 @@ function main(argv, deps) {
2415
2986
  notes: new Set(),
2416
2987
  stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
2417
2988
  };
2418
- const args = parseArgs(argv);
2419
- if (args.kind === 'usage') {
2989
+ const parsed = parseArgs(argv);
2990
+ if (parsed.kind === 'usage') {
2420
2991
  io.stderr(USAGE + '\n');
2421
2992
  return { code: EXIT_CODES.USAGE, stdout: '' };
2422
2993
  }
2994
+ let args = parsed;
2423
2995
  /** @type {Outcome} */
2424
2996
  let outcome;
2425
2997
  try {
2426
- if (args.kind === 'check') outcome = runCheck(io, args);
2427
- else if (args.kind === 'render') outcome = runRender(io, args);
2428
- else if (args.kind === 'verify') outcome = runVerify(io, args, d);
2429
- else if (args.kind === 'splice') outcome = runSplice(io, args);
2430
- else if (args.kind === 'publish') outcome = runPublish(io, args, d);
2431
- else if (args.kind === 'readiness') outcome = runReadiness(io, args, d);
2432
- 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
+ }
2433
3010
  } catch (err) {
2434
3011
  // An injected exec that throws, or a broken invariant (rev): no verdict. The two
2435
3012
  // scripted operations still answer in their own DEGRADED form, since a caller
2436
3013
  // prints whatever they print.
2437
3014
  const label = 'internal error (' + errorLabel(err) + ')';
2438
3015
  if (args.kind === 'publish') outcome = evidenceUnavailable(io, label);
2439
- else if (args.kind === 'readiness') outcome = readinessDegraded(args.pr, 'internal error', {});
2440
- 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);
2441
3021
  }
2442
3022
  if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
2443
3023
  return outcome;
@@ -2476,7 +3056,7 @@ function settleOutcome(outcome) {
2476
3056
  if (o.stdout.startsWith('## PR Evidence\n')) {
2477
3057
  return o.code === EXIT_CODES.OK && parsePublishBlock(o.stdout) !== null ? pass : failed;
2478
3058
  }
2479
- 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;
2480
3060
  if (o.stdout.startsWith('## Merge Readiness\n')) {
2481
3061
  const block = parseReadinessBlock(o.stdout);
2482
3062
  if (block === null) return failed;
@@ -2532,8 +3112,12 @@ module.exports = Object.freeze({
2532
3112
  BODY_STATUSES,
2533
3113
  COMMENT_STATUSES,
2534
3114
  readinessVerdict,
3115
+ bindCiHead,
2535
3116
  renderPublishBlock,
2536
3117
  parsePublishBlock,
2537
3118
  renderReadinessBlock,
2538
3119
  parseReadinessBlock,
3120
+ writeReadinessOut,
3121
+ commentIdOf,
3122
+ editTarget,
2539
3123
  });