devflow-kit 3.5.0 → 3.7.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 (146) hide show
  1. package/CHANGELOG.md +178 -1
  2. package/README.md +10 -6
  3. package/dist/agents/code.md +7 -11
  4. package/dist/agents/{diagnose.md → debug.md} +4 -4
  5. package/dist/agents/{code-fix.md → fix.md} +7 -8
  6. package/dist/agents/{scrutinize.md → harden.md} +6 -6
  7. package/dist/agents/{code-edit.md → refactor.md} +8 -9
  8. package/dist/agents/review.md +2 -0
  9. package/dist/cli/agents-view/render.js +1 -1
  10. package/dist/cli/commands/gates.js +276 -0
  11. package/dist/cli/commands/init-seed.js +9 -1
  12. package/dist/cli/commands/init.js +34 -11
  13. package/dist/cli/commands/learning.js +12 -12
  14. package/dist/cli/commands/legacy-hooks.js +1 -1
  15. package/dist/cli/commands/memory.js +18 -0
  16. package/dist/cli/commands/uninstall.js +111 -5
  17. package/dist/cli.js +2 -0
  18. package/dist/commands/bug-analysis.md +21 -17
  19. package/dist/commands/code-review.md +65 -66
  20. package/dist/commands/debug.md +17 -17
  21. package/dist/commands/dynamic-build.md +104 -116
  22. package/dist/commands/dynamic-plan.md +13 -11
  23. package/dist/commands/dynamic-profile.md +9 -7
  24. package/dist/commands/dynamic-tickets.md +13 -11
  25. package/dist/commands/explore.md +15 -15
  26. package/dist/commands/implement.md +80 -154
  27. package/dist/commands/plan.md +34 -66
  28. package/dist/commands/release.md +11 -7
  29. package/dist/commands/research.md +14 -10
  30. package/dist/commands/resolve.md +44 -88
  31. package/dist/commands/self-review.md +44 -44
  32. package/dist/core/agent-models.js +47 -11
  33. package/dist/core/{learning-tuning-config.js → curate-tuning-config.js} +14 -14
  34. package/dist/core/echo-value.js +42 -0
  35. package/dist/core/evidence-policy.js +17 -5
  36. package/dist/core/gate-level.js +213 -0
  37. package/dist/core/learning-queue-cleanup.js +1 -1
  38. package/dist/core/manifest.js +26 -10
  39. package/dist/core/migrations.js +63 -44
  40. package/dist/core/model-discovery.js +7 -1
  41. package/dist/core/plugins.js +14 -12
  42. package/dist/core/project-paths.js +3 -3
  43. package/dist/core/tracker.js +5 -33
  44. package/dist/hud/codex-usage.js +247 -0
  45. package/dist/hud/components/codex-quota.js +37 -0
  46. package/dist/hud/components/usage-quota.js +1 -1
  47. package/dist/hud/config.js +1 -0
  48. package/dist/hud/index.js +16 -3
  49. package/dist/hud/render.js +3 -1
  50. package/dist/learning-off/agents/code.md +7 -11
  51. package/dist/learning-off/agents/{diagnose.md → debug.md} +4 -4
  52. package/dist/learning-off/agents/{code-fix.md → fix.md} +7 -8
  53. package/dist/learning-off/agents/{scrutinize.md → harden.md} +6 -6
  54. package/dist/learning-off/agents/{code-edit.md → refactor.md} +8 -9
  55. package/dist/learning-off/agents/review.md +2 -0
  56. package/dist/learning-off/commands/bug-analysis.md +9 -9
  57. package/dist/learning-off/commands/code-review.md +54 -59
  58. package/dist/learning-off/commands/debug.md +6 -10
  59. package/dist/learning-off/commands/dynamic-build.md +98 -112
  60. package/dist/learning-off/commands/dynamic-plan.md +5 -5
  61. package/dist/learning-off/commands/dynamic-profile.md +1 -1
  62. package/dist/learning-off/commands/dynamic-tickets.md +5 -5
  63. package/dist/learning-off/commands/explore.md +4 -8
  64. package/dist/learning-off/commands/implement.md +68 -146
  65. package/dist/learning-off/commands/plan.md +23 -58
  66. package/dist/learning-off/commands/release.md +4 -2
  67. package/dist/learning-off/commands/research.md +2 -2
  68. package/dist/learning-off/commands/resolve.md +33 -81
  69. package/dist/learning-off/commands/self-review.md +32 -36
  70. package/dist/skills/git/references/pr/check-merge-readiness.md +2 -0
  71. package/dist/skills/git/references/pr/update-pr-evidence.md +4 -6
  72. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +7 -9
  73. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +7 -15
  74. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +7 -15
  75. package/dist/targets/claude-code/installer.js +117 -84
  76. package/dist/targets/claude-code/learning-install.js +15 -3
  77. package/dist/targets/claude-code/tracker-servers.js +1 -1
  78. package/package.json +1 -1
  79. package/src/assets/agents/_partials/_code.mds +7 -8
  80. package/src/assets/agents/code.mds +4 -7
  81. package/src/assets/agents/{learning.md → curate.md} +8 -10
  82. package/src/assets/agents/{diagnose.mds → debug.mds} +4 -4
  83. package/src/assets/agents/evaluate.md +4 -4
  84. package/src/assets/agents/{code-fix.mds → fix.mds} +5 -5
  85. package/src/assets/agents/{scrutinize.mds → harden.mds} +6 -6
  86. package/src/assets/agents/{simplify.md → polish.md} +4 -4
  87. package/src/assets/agents/{code-edit.mds → refactor.mds} +6 -6
  88. package/src/assets/agents/review.mds +2 -0
  89. package/src/assets/agents/synthesize.md +2 -2
  90. package/src/assets/agents/test.md +2 -2
  91. package/src/assets/commands/_partials/_compliance.mds +1 -1
  92. package/src/assets/commands/_partials/_decisions.mds +26 -6
  93. package/src/assets/commands/_partials/_engine.mds +15 -15
  94. package/src/assets/commands/_partials/_evidence_policy.mds +0 -17
  95. package/src/assets/commands/_partials/_knowledge.mds +14 -9
  96. package/src/assets/commands/_partials/_preamble.mds +4 -2
  97. package/src/assets/commands/_partials/_roster.mds +4 -4
  98. package/src/assets/commands/_partials/_settings.mds +2 -2
  99. package/src/assets/commands/_partials/_wave.mds +10 -17
  100. package/src/assets/commands/bug-analysis.mds +7 -7
  101. package/src/assets/commands/code-review.mds +51 -56
  102. package/src/assets/commands/debug.mds +2 -2
  103. package/src/assets/commands/dynamic-build.mds +67 -74
  104. package/src/assets/commands/implement.mds +65 -127
  105. package/src/assets/commands/plan.mds +21 -61
  106. package/src/assets/commands/release.mds +5 -1
  107. package/src/assets/commands/resolve.mds +28 -76
  108. package/src/assets/commands/self-review.mds +27 -27
  109. package/src/assets/mds/git/_ops_steps.mds +3 -3
  110. package/src/assets/mds/git/_pr.mds +6 -6
  111. package/src/assets/mds/tracker/_common.mds +23 -12
  112. package/src/assets/mds/tracker/_github.mds +2 -9
  113. package/src/assets/mds/tracker/_jira.mds +2 -15
  114. package/src/assets/mds/tracker/_linear.mds +2 -15
  115. package/src/assets/scripts/ci-wait.cjs +22 -4
  116. package/src/assets/scripts/claude-md-audit.cjs +66 -51
  117. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -3
  118. package/src/assets/scripts/hooks/background-memory-update +404 -95
  119. package/src/assets/scripts/hooks/capture-prompt +1 -1
  120. package/src/assets/scripts/hooks/capture-question +1 -1
  121. package/src/assets/scripts/hooks/capture-turn +1 -1
  122. package/src/assets/scripts/hooks/json-helper.cjs +13 -2
  123. package/src/assets/scripts/hooks/json-parse +18 -3
  124. package/src/assets/scripts/hooks/lib/learning-store.cjs +1 -1
  125. package/src/assets/scripts/hooks/lib/project-paths.cjs +4 -4
  126. package/src/assets/scripts/hooks/memory-permission +358 -0
  127. package/src/assets/scripts/hooks/preamble +1 -1
  128. package/src/assets/scripts/hooks/session-start-context +60 -47
  129. package/src/assets/scripts/hooks/session-start-memory +26 -5
  130. package/src/assets/scripts/lib/project-config.cjs +19 -4
  131. package/src/assets/scripts/resolve-settings.cjs +200 -32
  132. package/src/assets/scripts/verify-evidence.cjs +726 -9
  133. package/src/assets/skills/agent-references/SKILL.md +14 -6
  134. package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
  135. package/src/assets/skills/agent-references/references/commands/implement-evidence-asks.md +35 -0
  136. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +15 -0
  137. package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
  138. package/src/assets/skills/agent-references/references/synthesize/bug-analysis.md +1 -1
  139. package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
  140. package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
  141. package/src/assets/skills/apply-decisions/SKILL.md +6 -4
  142. package/src/assets/skills/docs-framework/SKILL.md +3 -3
  143. package/src/assets/skills/qa/SKILL.md +1 -1
  144. package/src/assets/skills/quality-gates/SKILL.md +7 -7
  145. package/src/assets/skills/quality-gates/references/report-template.md +7 -7
  146. /package/src/assets/skills/agent-references/references/{learning → curate}/maintain.md +0 -0
@@ -15,9 +15,11 @@
15
15
  // [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]
16
16
  // node verify-evidence.cjs splice --pr <n> --state <dir> --block <file> --out <file>
17
17
  // node verify-evidence.cjs readback --pr <n> --expect <file>
18
+ // node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]
19
+ // node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]
18
20
  //
19
- // stdout (D-VERIFY-STDOUT) is empty or exactly one of these, each a closed-vocabulary
20
- // line (the render block's TP lines come from the caller's own plan file):
21
+ // stdout (D-VERIFY-STDOUT) is empty or exactly one of these shapes, each closed-vocabulary
22
+ // (the render block's TP lines come from the caller's own plan file):
21
23
  // check nothing — the exit code is the answer; the diagnostic is on stderr
22
24
  // render the creation block: markers, `## Test Plan`, every TP unticked
23
25
  // verify EVIDENCE pr:<n> head:<sha> total:<n> VERIFIED-CI:<n> ATTESTED-LOCAL:<n>
@@ -26,6 +28,13 @@
26
28
  // posted:<yes|no|n/a> body:<same|changed> (one line; wrapped here)
27
29
  // splice SPLICE ok|resplice (exit 0) or SPLICE conflict|malformed|oversize (exit 5)
28
30
  // readback READBACK ok (exit 0) or READBACK mismatch (exit 5)
31
+ // publish the `## PR Evidence` block — the heading, the EVIDENCE line, one
32
+ // `**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)
35
+ // readiness the `## Merge Readiness` block (exit 0), or the same block with
36
+ // `**Status**: DEGRADED (<reason>)` and one `TRACEABILITY: DEGRADED
37
+ // (<reason>)` line after it (exit 2)
29
38
  // No byte of a PR body, a comment, a review or a gh/git answer is ever printed: the
30
39
  // boundary re-checks every stdout line against these shapes before writing it.
31
40
  //
@@ -34,7 +43,10 @@
34
43
  // 1 usage error — stdout entirely empty, usage on stderr
35
44
  // 2 input unusable — a file could not be read (missing, not a regular file,
36
45
  // oversize, not UTF-8), the evidence file or the state directory is
37
- // malformed, or the PR body's test-plan block cannot serve as the plan
46
+ // malformed, or the PR body's test-plan block cannot serve as the plan.
47
+ // For `publish` and `readiness` (D-OP-SCRIPT-POINTER) it is the DEGRADED
48
+ // result: the operation could not read what it needs, and the stdout above
49
+ // says so
38
50
  // 3 output write failed — no stdout line is printed
39
51
  // 4 remote or internal failure before any verdict — `gh pr view` failed or
40
52
  // answered something unusable, or an unexpected internal error
@@ -58,10 +70,15 @@
58
70
 
59
71
  const fs = require('fs');
60
72
  const path = require('path');
73
+ const os = require('os');
61
74
  const crypto = require('crypto');
62
75
  const childProcess = require('child_process');
63
76
 
64
77
  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.
81
+ const CW = require(path.join(__dirname, 'ci-wait.cjs'));
65
82
 
66
83
  // ---------------------------------------------------------------------------
67
84
  // Closed vocabularies and bounds
@@ -103,6 +120,28 @@ const CAPS = Object.freeze({
103
120
  */
104
121
  const DEADLINE_MS = 90000;
105
122
 
123
+ /**
124
+ * D-PUBLISH-BOUNDS: `publish` runs `verify` under the deadline above, then its writes
125
+ * 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.
131
+ */
132
+ const PUBLISH_DEADLINE_MS = 60000;
133
+ const PUBLISH_GH_CALLS = 8;
134
+ /** The scrubber is a short local script; a longer run is a failed one. */
135
+ const REDACT_TIMEOUT_MS = 20000;
136
+ /** A comment is re-measured after the scrub (redaction can grow it); GitHub rejects 65,536. */
137
+ const COMMENT_POST_MAX_CHARS = PE.LIMITS.BODY_CHARS;
138
+ /** The scrubber, a sibling of this script under ~/.devflow/scripts/. */
139
+ const REDACT_SCRIPT = path.join(__dirname, 'redact-secrets.cjs');
140
+ /** What a `publish` `--evidence` value may spell: the relative paths the commands build. */
141
+ const EVIDENCE_PATH_RE = /^[A-Za-z0-9._/-]{1,255}$/;
142
+ /** The `--publication` values a caller may pass (REVIEW_PUBLICATION, resolved). */
143
+ const PUBLICATION_VALUES = Object.freeze(['auto', 'full', 'off', 'stub']);
144
+
106
145
  const GH_TIMEOUT_MS = 20000;
107
146
  const GIT_FETCH_TIMEOUT_MS = 60000;
108
147
  const GIT_LOCAL_TIMEOUT_MS = 20000;
@@ -112,6 +151,9 @@ const PR_VIEW_MAX_BUFFER = 64 * 1024 * 1024;
112
151
  const RUN_LIST_MAX_BUFFER = 1024 * 1024;
113
152
  const RUN_VIEW_MAX_BUFFER = 65536;
114
153
  const LINE_MAX_BUFFER = 4096;
154
+ /** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
155
+ const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
156
+ const READINESS_MAX_CHECKS = 1000;
115
157
  /** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
116
158
  const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
117
159
  const FETCH_MAX_BUFFER = 1024 * 1024;
@@ -145,6 +187,8 @@ const USAGE = [
145
187
  ' [--state <dir>] [--block-out <file>] [--comment-out <file>] [--stale-out <file>] [--approval]',
146
188
  ' node verify-evidence.cjs splice --pr <n> --state <dir> --block <file> --out <file>',
147
189
  ' node verify-evidence.cjs readback --pr <n> --expect <file>',
190
+ ' node verify-evidence.cjs publish --pr <n> [--publication auto|full|off|stub] [--evidence <file>]',
191
+ ' node verify-evidence.cjs readiness --pr <n> [--require-non-author-approval true|false]',
148
192
  ].join('\n');
149
193
 
150
194
  // ---------------------------------------------------------------------------
@@ -164,10 +208,12 @@ const USAGE = [
164
208
  * @typedef {{ ghCalls: number, runViews: number, throttled: boolean }} Budget
165
209
  *
166
210
  * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv, cwd: string, now: () => number,
167
- * deadline: number, budget: Budget, notes: Set<string>, stderr: (text: string) => void }} Io
211
+ * deadline: number, budget: Budget, notes: Set<string>, stderr: (text: string) => void,
212
+ * ghCap?: number }} Io
168
213
  * The imperative shell's one context: every call goes through it, so every
169
214
  * bound is counted in one place. `notes` holds closed tokens for the stderr
170
- * summary, never input bytes.
215
+ * summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
216
+ * carries a budget of its own (`publish`'s writes).
171
217
  *
172
218
  * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean }} PrComment
173
219
  * @typedef {{ login: string, association: string, state: string }} PrReview
@@ -194,7 +240,9 @@ const USAGE = [
194
240
  * | { kind: 'verify', pr: number, publication: string, evidence: string | null, state: string | null,
195
241
  * blockOut: string | null, commentOut: string | null, staleOut: string | null, approval: boolean }
196
242
  * | { kind: 'splice', pr: number, state: string, block: string, out: string }
197
- * | { kind: 'readback', pr: number, expect: string }} ParsedArgs
243
+ * | { kind: 'readback', pr: number, expect: string }
244
+ * | { kind: 'publish', pr: number, publication: string, evidence: string | null }
245
+ * | { kind: 'readiness', pr: number, requireNonAuthor: 'true' | 'false' | 'unrecognised' }} ParsedArgs
198
246
  */
199
247
 
200
248
  /** @type {ParsedArgs} */
@@ -299,6 +347,25 @@ function parseArgs(argv) {
299
347
  const expect = f === null ? null : flagValue(f, '--expect');
300
348
  return pr === null || expect === null ? USAGE_ARGS : { kind: 'readback', pr, expect };
301
349
  }
350
+ if (sub === 'publish') {
351
+ const f = parseFlags(tail, { '--pr': 'value', '--publication': 'value', '--evidence': 'value' });
352
+ const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
353
+ if (f === null || pr === null) return USAGE_ARGS;
354
+ const publication = flagValue(f, '--publication') || '';
355
+ const evidence = flagValue(f, '--evidence');
356
+ if (publication !== '' && !PUBLICATION_VALUES.includes(publication)) return USAGE_ARGS;
357
+ if (evidence !== null && !EVIDENCE_PATH_RE.test(evidence)) return USAGE_ARGS;
358
+ return { kind: 'publish', pr, publication, evidence };
359
+ }
360
+ if (sub === 'readiness') {
361
+ const f = parseFlags(tail, { '--pr': 'value', '--require-non-author-approval': 'value' });
362
+ const pr = f === null ? null : parsePr(flagValue(f, '--pr'));
363
+ if (f === null || pr === null) return USAGE_ARGS;
364
+ const needed = flagValue(f, '--require-non-author-approval');
365
+ if (needed !== null && needed !== 'true' && needed !== 'false') return USAGE_ARGS;
366
+ // An absent flag is the ladder's "unrecognised": it can never read READY.
367
+ return { kind: 'readiness', pr, requireNonAuthor: needed === null ? 'unrecognised' : needed };
368
+ }
302
369
  return USAGE_ARGS;
303
370
  }
304
371
 
@@ -503,7 +570,8 @@ function remaining(io) {
503
570
  */
504
571
  function gh(io, args, maxBuffer) {
505
572
  if (io.budget.throttled) { io.notes.add('throttled'); return refusedCall('throttled'); }
506
- if (io.budget.ghCalls >= CAPS.GH_CALLS) { io.notes.add('gh-call-cap'); return refusedCall('cap'); }
573
+ const cap = typeof io.ghCap === 'number' ? io.ghCap : CAPS.GH_CALLS;
574
+ if (io.budget.ghCalls >= cap) { io.notes.add('gh-call-cap'); return refusedCall('cap'); }
507
575
  const left = remaining(io);
508
576
  if (left === 0) { io.notes.add('deadline'); return refusedCall('deadline'); }
509
577
  io.budget.ghCalls++;
@@ -1660,6 +1728,629 @@ function runRender(io, args) {
1660
1728
  return { code: EXIT_CODES.OK, stdout: block.value.text + '\n' };
1661
1729
  }
1662
1730
 
1731
+ // ---------------------------------------------------------------------------
1732
+ // The two scripted operations (D-OP-SCRIPT-POINTER)
1733
+ // ---------------------------------------------------------------------------
1734
+ //
1735
+ // `update-pr-evidence` and `check-merge-readiness` stay defined Git operations, so a
1736
+ // direct delegation still works, but each one's steps are "run this script and print
1737
+ // its stdout": the two did only plumbing, and a Git spawn paid a model's turns for it.
1738
+ // A command now runs `publish` or `readiness` through its own Bash tool. Both answer
1739
+ // in one closed-vocabulary block, re-parsed and re-rendered at the boundary
1740
+ // (D-OP-BLOCK-GATE), so a gh answer, a PR body or a check name never reaches stdout
1741
+ // except as a sanitized token.
1742
+
1743
+ /**
1744
+ * A DEGRADED reason on stdout: lower-case words, digits, spaces and hyphens. Every
1745
+ * reason this file prints is a fixed phrase; the shape is the boundary's re-check.
1746
+ */
1747
+ const REASON_RE = /^[a-z0-9][a-z0-9 -]{0,59}$/;
1748
+
1749
+ /** What a `publish` result prints when it could not run at all (exit 2). */
1750
+ const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)';
1751
+
1752
+ /**
1753
+ * @typedef {{ status: string, reason: string | null }} Part
1754
+ * One half of the `publish` answer: a closed status, or `DEGRADED` with its reason.
1755
+ */
1756
+
1757
+ const BODY_STATUSES = Object.freeze(['EDITED', 'UNCHANGED', 'SKIPPED']);
1758
+ const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF']);
1759
+
1760
+ /**
1761
+ * @param {string} status
1762
+ * @returns {Part}
1763
+ */
1764
+ function okPart(status) {
1765
+ return Object.freeze({ status, reason: null });
1766
+ }
1767
+
1768
+ /**
1769
+ * @param {string} reason a fixed phrase from this file
1770
+ * @returns {Part}
1771
+ */
1772
+ function degradedPart(reason) {
1773
+ return Object.freeze({ status: 'DEGRADED', reason });
1774
+ }
1775
+
1776
+ /**
1777
+ * @param {Part} part
1778
+ * @returns {string}
1779
+ */
1780
+ function partText(part) {
1781
+ return part.status === 'DEGRADED' ? 'DEGRADED (' + part.reason + ')' : part.status;
1782
+ }
1783
+
1784
+ /**
1785
+ * The reasons that earn a TRACEABILITY line, in print order and without repeats: a
1786
+ * degraded half names its reason, and a body edit skipped for a concurrent edit names
1787
+ * that (D4: the operation never blocks, and says what it could not do).
1788
+ *
1789
+ * @param {Part} body
1790
+ * @param {Part} comment
1791
+ * @returns {string[]}
1792
+ */
1793
+ function traceReasons(body, comment) {
1794
+ /** @type {string[]} */
1795
+ const out = [];
1796
+ const add = reason => { if (reason !== null && !out.includes(reason)) out.push(reason); };
1797
+ add(body.status === 'DEGRADED' ? body.reason : body.status === 'SKIPPED' ? 'concurrent edit' : null);
1798
+ add(comment.status === 'DEGRADED' ? comment.reason : null);
1799
+ return out;
1800
+ }
1801
+
1802
+ /**
1803
+ * The `## PR Evidence` block of the update-pr-evidence contract, then its TRACEABILITY
1804
+ * lines.
1805
+ *
1806
+ * @param {string} evidenceLine
1807
+ * @param {Part} body
1808
+ * @param {Part} comment
1809
+ * @returns {string}
1810
+ */
1811
+ function renderPublishBlock(evidenceLine, body, comment) {
1812
+ const lines = [
1813
+ '## PR Evidence',
1814
+ evidenceLine,
1815
+ '**Body**: ' + partText(body) + ' · **Comment**: ' + partText(comment),
1816
+ ...traceReasons(body, comment).map(r => 'TRACEABILITY: DEGRADED (' + r + ')'),
1817
+ ];
1818
+ return lines.join('\n') + '\n';
1819
+ }
1820
+
1821
+ const PUBLISH_PARTS_RE = /^\*\*Body\*\*: (?<body>[A-Z]+|DEGRADED \([a-z0-9][a-z0-9 -]{0,59}\)) · \*\*Comment\*\*: (?<comment>[A-Z]+|DEGRADED \([a-z0-9][a-z0-9 -]{0,59}\))$/;
1822
+
1823
+ /**
1824
+ * @param {string} text
1825
+ * @param {readonly string[]} statuses
1826
+ * @returns {Part | null}
1827
+ */
1828
+ function parsePart(text, statuses) {
1829
+ if (statuses.includes(text)) return okPart(text);
1830
+ const m = /^DEGRADED \((.+)\)$/.exec(text);
1831
+ return m !== null && REASON_RE.test(m[1]) ? degradedPart(m[1]) : null;
1832
+ }
1833
+
1834
+ /**
1835
+ * D-OP-BLOCK-GATE: read a `publish` block back into its parts, or null when it is not
1836
+ * exactly what renderPublishBlock prints. The boundary prints a block only when this
1837
+ * accepts it.
1838
+ *
1839
+ * @param {string} text the whole stdout, its final newline included
1840
+ * @returns {{ evidenceLine: string, body: Part, comment: Part } | null}
1841
+ */
1842
+ function parsePublishBlock(text) {
1843
+ if (typeof text !== 'string' || !text.endsWith('\n') || text.length > 8192) return null;
1844
+ const lines = text.slice(0, -1).split('\n');
1845
+ if (lines.length < 3 || lines.length > 5 || lines[0] !== '## PR Evidence') return null;
1846
+ if (!PE.parseEvidenceLine(lines[1]).ok) return null;
1847
+ const m = PUBLISH_PARTS_RE.exec(lines[2]);
1848
+ if (m === null || m.groups === undefined) return null;
1849
+ const body = parsePart(m.groups.body, BODY_STATUSES);
1850
+ const comment = parsePart(m.groups.comment, COMMENT_STATUSES);
1851
+ if (body === null || comment === null) return null;
1852
+ return renderPublishBlock(lines[1], body, comment) === text ? { evidenceLine: lines[1], body, comment } : null;
1853
+ }
1854
+
1855
+ /**
1856
+ * A `publish` that could not run: the one line, exit 2, and the reason on stderr.
1857
+ *
1858
+ * @param {Io} io
1859
+ * @param {string} reason a fixed phrase from this file, for stderr
1860
+ * @returns {Outcome}
1861
+ */
1862
+ function evidenceUnavailable(io, reason) {
1863
+ io.stderr('verify-evidence: publish: ' + reason + '\n');
1864
+ return { code: EXIT_CODES.INPUT_UNUSABLE, stdout: EVIDENCE_UNAVAILABLE_LINE + '\n' };
1865
+ }
1866
+
1867
+ /**
1868
+ * D-PUBLISH-SCRUB-GATE: the D11 scrub of a file bound for a PR, the file sink. True
1869
+ * only when the scrubber ran and exited 0; a missing script, a crash, a timeout and a
1870
+ * non-zero exit are all "do not post". The caller posts nothing after a false.
1871
+ *
1872
+ * @param {Io} io
1873
+ * @param {string} input
1874
+ * @param {string} output
1875
+ * @returns {boolean}
1876
+ */
1877
+ function scrub(io, input, output) {
1878
+ return runCall(io, process.execPath, [REDACT_SCRIPT, input, output], REDACT_TIMEOUT_MS, LINE_MAX_BUFFER).ok;
1879
+ }
1880
+
1881
+ /**
1882
+ * @param {CallResult} r
1883
+ * @returns {boolean} true when the call failed with an HTTP 5xx answer
1884
+ */
1885
+ function isServerError(r) {
1886
+ return !r.ok && answered(r) && /HTTP 5[0-9]{2}\b/.test(r.stderr);
1887
+ }
1888
+
1889
+ /**
1890
+ * One gh write with the retry the operation allows: a 5xx answer is tried once more,
1891
+ * never a second time, and nothing else is retried.
1892
+ *
1893
+ * @param {Io} io
1894
+ * @param {readonly string[]} args
1895
+ * @returns {{ ok: boolean, serverError: boolean }}
1896
+ */
1897
+ function ghWrite(io, args) {
1898
+ let r = gh(io, args, LINE_MAX_BUFFER);
1899
+ if (isServerError(r)) r = gh(io, args, LINE_MAX_BUFFER);
1900
+ return { ok: r.ok, serverError: isServerError(r) };
1901
+ }
1902
+
1903
+ /**
1904
+ * The context for the writes: a gh budget and a deadline of their own, the same
1905
+ * notes. The throttle flag carries over, so a rate limit that verify hit stops the
1906
+ * writes too.
1907
+ *
1908
+ * @param {Io} io
1909
+ * @returns {Io}
1910
+ */
1911
+ function writeStageIo(io) {
1912
+ return Object.assign({}, io, {
1913
+ deadline: io.now() + PUBLISH_DEADLINE_MS,
1914
+ budget: { ghCalls: 0, runViews: 0, throttled: io.budget.throttled },
1915
+ ghCap: PUBLISH_GH_CALLS,
1916
+ });
1917
+ }
1918
+
1919
+ /**
1920
+ * The body half of update-pr-evidence: scrub the composed block, splice it into the
1921
+ * body (the compare-and-swap), edit, read back. Each step runs only when the one
1922
+ * before it succeeded, so `gh pr edit` never runs after a failed scrub or a refused
1923
+ * splice.
1924
+ *
1925
+ * @param {Io} io the write-stage context
1926
+ * @param {number} pr
1927
+ * @param {{ body: string }} fields the EVIDENCE line's fields
1928
+ * @param {{ state: string, notesRaw: string, notes: string, body: string }} files
1929
+ * @returns {Part}
1930
+ */
1931
+ function publishBody(io, pr, fields, files) {
1932
+ if (fields.body !== 'changed') return okPart('UNCHANGED');
1933
+ if (!scrub(io, files.notesRaw, files.notes)) return degradedPart('redaction unavailable');
1934
+ const spliced = runSplice(io, { kind: 'splice', pr, state: files.state, block: files.notes, out: files.body });
1935
+ const token = SPLICE_LINE_RE.exec(stripOneNewline(spliced.stdout));
1936
+ if (token === null || token.groups === undefined) return degradedPart('splice unavailable');
1937
+ const outcome = token.groups.outcome;
1938
+ if (outcome === 'conflict') return okPart('SKIPPED');
1939
+ if (outcome !== 'ok' && outcome !== 'resplice') return degradedPart('splice ' + outcome);
1940
+ 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');
1942
+ const back = runReadback(io, { kind: 'readback', pr, expect: files.body });
1943
+ return back.code === EXIT_CODES.OK && back.stdout === 'READBACK ok\n' ? okPart('EDITED') : degradedPart('body read-back mismatch');
1944
+ }
1945
+
1946
+ /**
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.
1949
+ *
1950
+ * @param {Io} io the write-stage context
1951
+ * @param {number} pr
1952
+ * @param {{ posted: string }} fields the EVIDENCE line's fields
1953
+ * @param {{ commentRaw: string, comment: string }} files
1954
+ * @returns {Part}
1955
+ */
1956
+ function publishComment(io, pr, fields, files) {
1957
+ if (fields.posted === 'yes') return okPart('SKIPPED');
1958
+ if (fields.posted === 'n/a') return okPart('OFF');
1959
+ if (!scrub(io, files.commentRaw, files.comment)) return degradedPart('redaction unavailable');
1960
+ const text = readTextFile(files.comment);
1961
+ if (text === null || text === '') return degradedPart('redaction unavailable');
1962
+ if (text.length > COMMENT_POST_MAX_CHARS) return degradedPart('comment over size cap');
1963
+ 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');
1966
+ }
1967
+
1968
+ /**
1969
+ * 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.
1973
+ *
1974
+ * @param {Io} io
1975
+ * @param {Extract<ParsedArgs, { kind: 'publish' }>} args
1976
+ * @param {MainDeps} deps
1977
+ * @returns {Outcome}
1978
+ */
1979
+ function runPublish(io, args, deps) {
1980
+ /** @type {string} */
1981
+ let dir;
1982
+ try {
1983
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devflow-evidence-'));
1984
+ } catch (_) {
1985
+ return evidenceUnavailable(io, 'the work directory could not be made');
1986
+ }
1987
+ try {
1988
+ const file = name => path.join(dir, name);
1989
+ const state = file('state');
1990
+ try {
1991
+ fs.mkdirSync(state, { mode: 0o700 });
1992
+ } catch (_) {
1993
+ return evidenceUnavailable(io, 'the state directory could not be made');
1994
+ }
1995
+ const files = {
1996
+ state,
1997
+ notesRaw: file('notes.raw'),
1998
+ notes: file('notes'),
1999
+ body: file('body'),
2000
+ commentRaw: file('comment.raw'),
2001
+ comment: file('comment'),
2002
+ };
2003
+ const verified = runVerify(io, {
2004
+ kind: 'verify', pr: args.pr, publication: args.publication, evidence: args.evidence, state,
2005
+ blockOut: files.notesRaw, commentOut: files.commentRaw, staleOut: null, approval: false,
2006
+ }, deps);
2007
+ if (verified.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
2008
+ const line = stripOneNewline(verified.stdout);
2009
+ const parsed = PE.parseEvidenceLine(line);
2010
+ if (!parsed.ok || parsed.value.pr !== args.pr) return evidenceUnavailable(io, 'verify answered something else');
2011
+
2012
+ const stage = writeStageIo(io);
2013
+ const body = publishBody(stage, args.pr, parsed.value, files);
2014
+ const comment = publishComment(stage, args.pr, parsed.value, files);
2015
+ return { code: EXIT_CODES.OK, stdout: renderPublishBlock(line, body, comment) };
2016
+ } finally {
2017
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* the OS clears its temp directory */ }
2018
+ }
2019
+ }
2020
+
2021
+ // --- readiness ----------------------------------------------------------------
2022
+
2023
+ /**
2024
+ * @typedef {{ total: number, verifiedCi: number, attestedLocal: number,
2025
+ * testPlanException: boolean, approval: string }} EvidenceFacts
2026
+ * What the ladder reads of the EVIDENCE line. Null means the evidence is unknown.
2027
+ *
2028
+ * @typedef {{ unresolved: number, approximate: boolean, decision: string | null,
2029
+ * ci: string, failing: string, evidence: EvidenceFacts | null, require: string }} ReadinessFacts
2030
+ * `decision` is APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED, null, or UNRECOGNISED;
2031
+ * `failing` is ci-wait's formatted failing-check names; `require` is `true`, `false`
2032
+ * or `unrecognised`.
2033
+ */
2034
+
2035
+ /**
2036
+ * D-READINESS-LADDER: check-merge-readiness step 5, first matching rule wins. READY is
2037
+ * a positive conjunction and the last arm is `status unknown`, so an input no arm
2038
+ * names can never read READY. tests/evidence/merge-readiness.test.ts holds this to
2039
+ * the reference's prose arms (same labels, same order) and to a model over the whole
2040
+ * fact domain.
2041
+ *
2042
+ * @param {ReadinessFacts} f
2043
+ * @returns {{ status: 'READY' | 'NOT_READY', reason: string | null }}
2044
+ */
2045
+ function readinessVerdict(f) {
2046
+ /** @param {string} reason */
2047
+ const notReady = reason => ({ status: /** @type {const} */ ('NOT_READY'), reason });
2048
+ const ev = f.evidence;
2049
+ if (f.unresolved > 0) return notReady('unresolved threads: ' + (f.approximate ? '>' : '') + f.unresolved);
2050
+ if (f.decision === 'CHANGES_REQUESTED') return notReady('changes requested');
2051
+ if (f.ci === 'FAILING') return notReady('CI failing: ' + f.failing);
2052
+ if (f.ci === 'PENDING') return notReady('CI pending');
2053
+ if (f.decision === 'REVIEW_REQUIRED' || f.decision === null) return notReady('no approving review');
2054
+ if (ev === null) return notReady('test-plan evidence unavailable');
2055
+ if (f.require === 'true' && ev.approval !== 'yes') return notReady('no non-author approval');
2056
+ const verified = ev.verifiedCi + ev.attestedLocal;
2057
+ if (ev.total === 0 && !ev.testPlanException) return notReady('no test-plan evidence');
2058
+ if (verified < ev.total) return notReady('test plan: ' + verified + '/' + ev.total + ' verified');
2059
+ const planHolds = (ev.total >= 1 && verified === ev.total) || (ev.total === 0 && ev.testPlanException);
2060
+ const ready = f.unresolved === 0 && !f.approximate && f.decision === 'APPROVED'
2061
+ && (f.ci === 'PASSING' || f.ci === 'NO_CI') && (ev.approval === 'yes' || f.require === 'false') && planHolds;
2062
+ return ready ? { status: 'READY', reason: null } : notReady('status unknown');
2063
+ }
2064
+
2065
+ /**
2066
+ * @typedef {{ pr: number, status: 'READY' | 'NOT_READY' | 'DEGRADED', reason: string | null,
2067
+ * threads: string, decision: string, ci: string, plan: string, approval: string }} ReadinessBlock
2068
+ * Every field after `reason` is the text of one Details line's value.
2069
+ */
2070
+
2071
+ /**
2072
+ * The `## Merge Readiness` block of the check-merge-readiness contract. A DEGRADED
2073
+ * block ends with its TRACEABILITY line.
2074
+ *
2075
+ * @param {ReadinessBlock} b
2076
+ * @returns {string}
2077
+ */
2078
+ function renderReadinessBlock(b) {
2079
+ const status = b.status === 'READY' ? 'READY' : b.status + ' (' + b.reason + ')';
2080
+ const lines = [
2081
+ '## Merge Readiness',
2082
+ '**PR**: #' + b.pr,
2083
+ '**Status**: ' + status,
2084
+ '',
2085
+ '### Details',
2086
+ '- Unresolved threads: ' + b.threads,
2087
+ '- Review decision: ' + b.decision,
2088
+ '- CI status: ' + b.ci,
2089
+ '- Test plan: ' + b.plan + ' · non-author approval: ' + b.approval,
2090
+ ];
2091
+ if (b.status === 'DEGRADED') lines.push('TRACEABILITY: DEGRADED (' + b.reason + ')');
2092
+ return lines.join('\n') + '\n';
2093
+ }
2094
+
2095
+ /** A NOT_READY reason: the ladder's fixed phrases, with numbers and sanitized check names. */
2096
+ const NOT_READY_REASON_RE = /^(?:unresolved threads: >?[0-9]{1,4}|changes requested|CI failing: [A-Za-z0-9 ._()/:+,-]{1,200}|CI pending|no approving review|test-plan evidence unavailable|no non-author approval|no test-plan evidence|test plan: [0-9]{1,3}\/[0-9]{1,3} verified|status unknown)$/;
2097
+ const READINESS_THREADS_RE = /^(?:[0-9]{1,4}|>[0-9]{1,4} \(count approximate — PR has more than 100 threads\)|unavailable)$/;
2098
+ const READINESS_DECISIONS = Object.freeze(['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED', 'none', 'unrecognised', 'unavailable']);
2099
+ const READINESS_CI = Object.freeze(['PASSING', 'FAILING', 'PENDING', 'NO_CI', 'NO_PR', 'INDETERMINATE', 'unavailable']);
2100
+ const READINESS_PLAN_RE = /^(?:[0-9]{1,3}\/[0-9]{1,3} \(VERIFIED-CI [0-9]{1,3}, ATTESTED-LOCAL [0-9]{1,3}\)|unavailable)$/;
2101
+ const READINESS_APPROVALS = Object.freeze(['yes', 'no', 'not required']);
2102
+
2103
+ /**
2104
+ * D-OP-BLOCK-GATE: read a `## Merge Readiness` block back into its fields, or null
2105
+ * when it is not exactly what renderReadinessBlock prints.
2106
+ *
2107
+ * @param {string} text the whole stdout, its final newline included
2108
+ * @returns {ReadinessBlock | null}
2109
+ */
2110
+ function parseReadinessBlock(text) {
2111
+ if (typeof text !== 'string' || !text.endsWith('\n') || text.length > 4096) return null;
2112
+ const lines = text.slice(0, -1).split('\n');
2113
+ if (lines.length !== 9 && lines.length !== 10) return null;
2114
+ const pr = /^\*\*PR\*\*: #([1-9][0-9]{0,9})$/.exec(lines[1]);
2115
+ const status = /^\*\*Status\*\*: (READY|(NOT_READY|DEGRADED) \((.+)\))$/.exec(lines[2]);
2116
+ const threads = /^- Unresolved threads: (.+)$/.exec(lines[5]);
2117
+ const decision = /^- Review decision: (.+)$/.exec(lines[6]);
2118
+ const ci = /^- CI status: (.+)$/.exec(lines[7]);
2119
+ const plan = /^- Test plan: (.+) · non-author approval: (.+)$/.exec(lines[8]);
2120
+ if (pr === null || status === null || threads === null || decision === null || ci === null || plan === null) return null;
2121
+ const kind = status[1] === 'READY' ? 'READY' : status[2];
2122
+ const reason = kind === 'READY' ? null : status[3];
2123
+ if (kind === 'NOT_READY' && !NOT_READY_REASON_RE.test(reason)) return null;
2124
+ if (kind === 'DEGRADED' && !REASON_RE.test(reason)) return null;
2125
+ if (!READINESS_THREADS_RE.test(threads[1]) || !READINESS_DECISIONS.includes(decision[1])) return null;
2126
+ if (!READINESS_CI.includes(ci[1]) || !READINESS_PLAN_RE.test(plan[1]) || !READINESS_APPROVALS.includes(plan[2])) return null;
2127
+ /** @type {ReadinessBlock} */
2128
+ const block = {
2129
+ pr: Number(pr[1]), status: /** @type {ReadinessBlock['status']} */ (kind), reason,
2130
+ threads: threads[1], decision: decision[1], ci: ci[1], plan: plan[1], approval: plan[2],
2131
+ };
2132
+ return renderReadinessBlock(block) === text ? block : null;
2133
+ }
2134
+
2135
+ /**
2136
+ * A read of the PR: its value, or the fixed reason it could not be read.
2137
+ *
2138
+ * @template T
2139
+ * @typedef {{ ok: true, value: T } | { ok: false, reason: string }} Read
2140
+ */
2141
+
2142
+ /**
2143
+ * @param {CallResult} r a failed call
2144
+ * @returns {string}
2145
+ */
2146
+ function readFailure(r) {
2147
+ if (r.refused === 'throttled') return 'rate limited';
2148
+ if (r.refused !== null) return 'gh budget spent';
2149
+ return /Could not resolve to a PullRequest/.test(r.stderr) ? 'no pr' : 'gh unavailable';
2150
+ }
2151
+
2152
+ /**
2153
+ * @param {unknown} text
2154
+ * @returns {unknown | null} the parsed JSON, or null
2155
+ */
2156
+ function parseJsonOrNull(text) {
2157
+ try {
2158
+ return JSON.parse(String(text));
2159
+ } catch (_) {
2160
+ return null;
2161
+ }
2162
+ }
2163
+
2164
+ /**
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.
2168
+ *
2169
+ * @param {Io} io
2170
+ * @param {number} pr
2171
+ * @returns {Read<string | null>}
2172
+ */
2173
+ function readDecision(io, pr) {
2174
+ const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision'], LINE_MAX_BUFFER);
2175
+ 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 };
2178
+ 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' };
2180
+ }
2181
+
2182
+ /**
2183
+ * D-READINESS-THREADS: the unresolved review threads among the first 100, with
2184
+ * `totalCount` deciding whether that count is approximate. The query is a constant;
2185
+ * the repository and the number are variables, never text in the query. A node whose
2186
+ * `isResolved` is not a boolean makes the whole read unreadable, since a thread that
2187
+ * cannot be told resolved must not read as resolved.
2188
+ */
2189
+ const THREADS_QUERY = 'query($owner: String!, $repo: String!, $pr: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pr) { reviewThreads(first: 100) { totalCount nodes { isResolved } } } } }';
2190
+ const THREADS_PAGE = 100;
2191
+
2192
+ /**
2193
+ * @param {Io} io
2194
+ * @param {number} pr
2195
+ * @returns {Read<{ unresolved: number, approximate: boolean }>}
2196
+ */
2197
+ function readThreads(io, pr) {
2198
+ const r = gh(io, [
2199
+ 'api', 'graphql', '-F', 'owner={owner}', '-F', 'repo={repo}', '-F', 'pr=' + pr, '-f', 'query=' + THREADS_QUERY,
2200
+ ], READINESS_MAX_BUFFER);
2201
+ if (!r.ok) return { ok: false, reason: readFailure(r) };
2202
+ const data = obj(obj(obj(obj(parseJsonOrNull(r.stdout.toString('utf8'))).data).repository).pullRequest);
2203
+ const threads = obj(data.reviewThreads);
2204
+ const nodes = threads.nodes;
2205
+ const total = threads.totalCount;
2206
+ if (!Array.isArray(nodes) || nodes.length > THREADS_PAGE || !Number.isInteger(total) || total < nodes.length) {
2207
+ return { ok: false, reason: 'review threads unreadable' };
2208
+ }
2209
+ let unresolved = 0;
2210
+ for (const node of nodes) {
2211
+ const resolved = obj(node).isResolved;
2212
+ if (typeof resolved !== 'boolean') return { ok: false, reason: 'review threads unreadable' };
2213
+ if (!resolved) unresolved++;
2214
+ }
2215
+ return { ok: true, value: { unresolved, approximate: total > THREADS_PAGE } };
2216
+ }
2217
+
2218
+ /**
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.
2223
+ *
2224
+ * @param {Io} io
2225
+ * @param {number} pr
2226
+ * @returns {{ status: string, failing: string }}
2227
+ */
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;
2232
+ if (r.status !== 0 && r.status !== 8) {
2233
+ 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 });
2244
+ }
2245
+ const c = CW.classifyChecks(checks);
2246
+ return { status: c.status, failing: CW.formatNames(c.failing) };
2247
+ }
2248
+
2249
+ /**
2250
+ * 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).
2252
+ *
2253
+ * @param {Io} io
2254
+ * @param {number} pr
2255
+ * @param {MainDeps} deps
2256
+ * @returns {any | null} the parsed EVIDENCE fields, or null when the evidence is unknown
2257
+ */
2258
+ function readEvidence(io, pr, deps) {
2259
+ const verified = runVerify(io, {
2260
+ kind: 'verify', pr, publication: '', evidence: null, state: null,
2261
+ blockOut: null, commentOut: null, staleOut: null, approval: true,
2262
+ }, deps);
2263
+ if (verified.code !== EXIT_CODES.OK) return null;
2264
+ const parsed = PE.parseEvidenceLine(stripOneNewline(verified.stdout));
2265
+ return parsed.ok && parsed.value.pr === pr ? parsed.value : null;
2266
+ }
2267
+
2268
+ /**
2269
+ * @param {string} require `true`, `false` or `unrecognised`
2270
+ * @param {EvidenceFacts | null} evidence
2271
+ * @returns {string}
2272
+ */
2273
+ function approvalText(require, evidence) {
2274
+ if (require === 'false') return 'not required';
2275
+ return evidence !== null && evidence.approval === 'yes' ? 'yes' : 'no';
2276
+ }
2277
+
2278
+ /**
2279
+ * A readiness answer that could not be reached: a DEGRADED block (exit 2) whose Details
2280
+ * say `unavailable` for what was never read.
2281
+ *
2282
+ * @param {number} pr
2283
+ * @param {string} reason
2284
+ * @param {Partial<ReadinessBlock>} known
2285
+ * @returns {Outcome}
2286
+ */
2287
+ function readinessDegraded(pr, reason, known) {
2288
+ return {
2289
+ code: EXIT_CODES.INPUT_UNUSABLE,
2290
+ stdout: renderReadinessBlock({
2291
+ pr, status: 'DEGRADED', reason,
2292
+ threads: known.threads || 'unavailable', decision: known.decision || 'unavailable',
2293
+ ci: known.ci || 'unavailable', plan: 'unavailable', approval: known.approval || 'no',
2294
+ }),
2295
+ };
2296
+ }
2297
+
2298
+ /**
2299
+ * The whole check-merge-readiness operation. Report-only: it reads, classifies and
2300
+ * prints; it never acts on the PR. A PR it cannot read, or a review decision or thread
2301
+ * count it cannot establish, is DEGRADED; an unreadable CI status or unknown evidence
2302
+ * is an input to the ladder, which never lets either reach READY.
2303
+ *
2304
+ * @param {Io} io
2305
+ * @param {Extract<ParsedArgs, { kind: 'readiness' }>} args
2306
+ * @param {MainDeps} deps
2307
+ * @returns {Outcome}
2308
+ */
2309
+ function runReadiness(io, args, deps) {
2310
+ 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;
2314
+ const threads = readThreads(io, args.pr);
2315
+ if (!threads.ok) {
2316
+ return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
2317
+ }
2318
+ const ci = readCi(io, args.pr);
2319
+ const fields = readEvidence(io, args.pr, deps);
2320
+ /** @type {EvidenceFacts | null} */
2321
+ const evidence = fields === null ? null : {
2322
+ total: fields.total,
2323
+ verifiedCi: fields.counts['VERIFIED-CI'],
2324
+ attestedLocal: fields.counts['ATTESTED-LOCAL'],
2325
+ testPlanException: fields.exceptions.includes('test-plan'),
2326
+ approval: fields.approval,
2327
+ };
2328
+ const verdict = readinessVerdict({
2329
+ unresolved: threads.value.unresolved,
2330
+ approximate: threads.value.approximate,
2331
+ decision: decision.value,
2332
+ ci: ci.status,
2333
+ failing: ci.failing,
2334
+ evidence,
2335
+ require: args.requireNonAuthor,
2336
+ });
2337
+ const block = {
2338
+ pr: args.pr,
2339
+ status: verdict.status,
2340
+ reason: verdict.reason,
2341
+ threads: threads.value.approximate
2342
+ ? '>' + threads.value.unresolved + ' (count approximate — PR has more than 100 threads)'
2343
+ : String(threads.value.unresolved),
2344
+ decision: decisionText,
2345
+ ci: ci.status,
2346
+ plan: evidence === null ? 'unavailable'
2347
+ : (evidence.verifiedCi + evidence.attestedLocal) + '/' + evidence.total
2348
+ + ' (VERIFIED-CI ' + evidence.verifiedCi + ', ATTESTED-LOCAL ' + evidence.attestedLocal + ')',
2349
+ approval: approvalText(args.requireNonAuthor, evidence),
2350
+ };
2351
+ return { code: EXIT_CODES.OK, stdout: renderReadinessBlock(block) };
2352
+ }
2353
+
1663
2354
  // ---------------------------------------------------------------------------
1664
2355
  // main — returns {code, stdout}; never calls process.exit
1665
2356
  // ---------------------------------------------------------------------------
@@ -1736,10 +2427,17 @@ function main(argv, deps) {
1736
2427
  else if (args.kind === 'render') outcome = runRender(io, args);
1737
2428
  else if (args.kind === 'verify') outcome = runVerify(io, args, d);
1738
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);
1739
2432
  else outcome = runReadback(io, args);
1740
2433
  } catch (err) {
1741
- // An injected exec that throws, or a broken invariant (rev): no verdict.
1742
- outcome = refuse(io, EXIT_CODES.REMOTE_FAILURE, 'internal error (' + errorLabel(err) + ')');
2434
+ // An injected exec that throws, or a broken invariant (rev): no verdict. The two
2435
+ // scripted operations still answer in their own DEGRADED form, since a caller
2436
+ // prints whatever they print.
2437
+ const label = 'internal error (' + errorLabel(err) + ')';
2438
+ 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);
1743
2441
  }
1744
2442
  if (io.notes.size > 0) io.stderr('verify-evidence: notes: ' + [...io.notes].sort().join(',') + '\n');
1745
2443
  return outcome;
@@ -1772,6 +2470,18 @@ function settleOutcome(outcome) {
1772
2470
  if (!Object.values(EXIT_CODES).includes(o.code)) return failed;
1773
2471
  if (o.code === EXIT_CODES.USAGE || o.stdout === '') return { code: o.code, stdout: '' };
1774
2472
  if (!o.stdout.endsWith('\n')) return failed;
2473
+ const pass = { code: o.code, stdout: o.stdout };
2474
+ // The two scripted operations (D-OP-BLOCK-GATE): a block prints only when it parses
2475
+ // back to exactly itself.
2476
+ if (o.stdout.startsWith('## PR Evidence\n')) {
2477
+ return o.code === EXIT_CODES.OK && parsePublishBlock(o.stdout) !== null ? pass : failed;
2478
+ }
2479
+ if (o.stdout === EVIDENCE_UNAVAILABLE_LINE + '\n') return o.code === EXIT_CODES.INPUT_UNUSABLE ? pass : failed;
2480
+ if (o.stdout.startsWith('## Merge Readiness\n')) {
2481
+ const block = parseReadinessBlock(o.stdout);
2482
+ if (block === null) return failed;
2483
+ return o.code === (block.status === 'DEGRADED' ? EXIT_CODES.INPUT_UNUSABLE : EXIT_CODES.OK) ? pass : failed;
2484
+ }
1775
2485
  const text = o.stdout.slice(0, -1);
1776
2486
  const splice = SPLICE_LINE_RE.exec(text);
1777
2487
  if (splice !== null && splice.groups !== undefined) {
@@ -1819,4 +2529,11 @@ module.exports = Object.freeze({
1819
2529
  PR_FIELDS,
1820
2530
  main,
1821
2531
  settleOutcome,
2532
+ BODY_STATUSES,
2533
+ COMMENT_STATUSES,
2534
+ readinessVerdict,
2535
+ renderPublishBlock,
2536
+ parsePublishBlock,
2537
+ renderReadinessBlock,
2538
+ parseReadinessBlock,
1822
2539
  });