devflow-kit 3.8.0 → 3.9.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 (94) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +5 -5
  3. package/dist/agents/code.md +4 -4
  4. package/dist/agents/fix.md +1 -1
  5. package/dist/agents/git.md +2 -2
  6. package/dist/agents/skim.md +1 -1
  7. package/dist/agents/triage.md +7 -0
  8. package/dist/cli/commands/curation.js +67 -16
  9. package/dist/cli/commands/gate-level-prompts.js +8 -6
  10. package/dist/cli/commands/gates.js +5 -4
  11. package/dist/cli/commands/init.js +8 -7
  12. package/dist/cli/commands/memory.js +59 -12
  13. package/dist/cli/commands/uninstall.js +77 -9
  14. package/dist/commands/bug-analysis.md +2 -2
  15. package/dist/commands/code-review.md +11 -11
  16. package/dist/commands/debug.md +2 -2
  17. package/dist/commands/dynamic-build.md +2 -2
  18. package/dist/commands/dynamic-plan.md +2 -2
  19. package/dist/commands/dynamic-profile.md +2 -2
  20. package/dist/commands/dynamic-tickets.md +2 -2
  21. package/dist/commands/explore.md +2 -2
  22. package/dist/commands/implement.md +2 -2
  23. package/dist/commands/plan.md +2 -2
  24. package/dist/commands/release.md +2 -2
  25. package/dist/commands/research.md +2 -2
  26. package/dist/commands/resolve.md +16 -16
  27. package/dist/commands/self-review.md +2 -2
  28. package/dist/core/area-pause-notice.js +44 -0
  29. package/dist/core/curation-move-skip.js +4 -5
  30. package/dist/core/curation-queue-cleanup.js +6 -5
  31. package/dist/core/curation-store.js +1 -0
  32. package/dist/core/evidence-policy.js +10 -8
  33. package/dist/core/plugins.js +4 -4
  34. package/dist/core/tracked-areas.js +138 -14
  35. package/dist/curation-off/agents/code.md +4 -4
  36. package/dist/curation-off/agents/fix.md +1 -1
  37. package/dist/curation-off/agents/triage.md +7 -0
  38. package/dist/curation-off/commands/code-review.md +11 -11
  39. package/dist/curation-off/commands/debug.md +2 -2
  40. package/dist/curation-off/commands/dynamic-build.md +2 -2
  41. package/dist/curation-off/commands/explore.md +2 -2
  42. package/dist/curation-off/commands/implement.md +2 -2
  43. package/dist/curation-off/commands/plan.md +2 -2
  44. package/dist/curation-off/commands/resolve.md +16 -16
  45. package/dist/curation-off/commands/self-review.md +2 -2
  46. package/dist/hud/components/curation-counts.js +27 -0
  47. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  48. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  49. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  50. package/dist/skills/git/references/publication-gate.md +6 -7
  51. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  52. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  53. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  54. package/package.json +1 -1
  55. package/src/assets/agents/code.mds +4 -4
  56. package/src/assets/agents/evaluate.md +6 -12
  57. package/src/assets/agents/fix.mds +1 -1
  58. package/src/assets/agents/git.mds +2 -2
  59. package/src/assets/agents/skim.mds +1 -1
  60. package/src/assets/agents/triage.mds +7 -0
  61. package/src/assets/agents/validate.md +20 -27
  62. package/src/assets/commands/_partials/_decisions.mds +10 -8
  63. package/src/assets/commands/_partials/_settings.mds +2 -2
  64. package/src/assets/commands/code-review.mds +9 -9
  65. package/src/assets/commands/resolve.mds +14 -14
  66. package/src/assets/mds/git/_ops.mds +6 -1
  67. package/src/assets/mds/git/_ops_pr.mds +20 -10
  68. package/src/assets/mds/git/_ops_steps.mds +10 -0
  69. package/src/assets/mds/git/_pr.mds +14 -14
  70. package/src/assets/mds/git/_references.mds +6 -7
  71. package/src/assets/mds/tracker/_github.mds +1 -4
  72. package/src/assets/mds/tracker/_jira.mds +1 -4
  73. package/src/assets/mds/tracker/_linear.mds +1 -4
  74. package/src/assets/scripts/ci-wait.cjs +389 -92
  75. package/src/assets/scripts/hooks/background-memory-update +79 -1
  76. package/src/assets/scripts/hooks/json-helper.cjs +44 -2
  77. package/src/assets/scripts/hooks/lib/curation-store.cjs +82 -20
  78. package/src/assets/scripts/hooks/lib/render-decisions.cjs +9 -1
  79. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +226 -33
  80. package/src/assets/scripts/hooks/memory-permission +10 -2
  81. package/src/assets/scripts/hooks/memory-worker +11 -0
  82. package/src/assets/scripts/hooks/pre-compact-memory +11 -0
  83. package/src/assets/scripts/hooks/queue-append +218 -1
  84. package/src/assets/scripts/hooks/session-start-context +89 -37
  85. package/src/assets/scripts/hooks/session-start-memory +39 -0
  86. package/src/assets/scripts/pr-evidence.cjs +5 -2
  87. package/src/assets/scripts/resolve-settings.cjs +244 -30
  88. package/src/assets/scripts/verify-evidence.cjs +307 -75
  89. package/src/assets/skills/agent-references/SKILL.md +7 -1
  90. package/src/assets/skills/agent-references/references/commands/implement-levels.md +6 -4
  91. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +3 -2
  92. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  93. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  94. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
@@ -75,9 +75,10 @@ const crypto = require('crypto');
75
75
  const childProcess = require('child_process');
76
76
 
77
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.
78
+ // D-READINESS-CLASSIFIER: the CI classifier, the merge gate and the workflow-list probe
79
+ // are ci-wait's own, so the two scripts cannot disagree; only ci-wait has the settle
80
+ // window, since readiness makes one read. Its main is guarded by
81
+ // `require.main === module`: requiring it starts nothing.
81
82
  const CW = require(path.join(__dirname, 'ci-wait.cjs'));
82
83
 
83
84
  // ---------------------------------------------------------------------------
@@ -123,11 +124,13 @@ const DEADLINE_MS = 90000;
123
124
  /**
124
125
  * D-PUBLISH-BOUNDS: `publish` runs `verify` under the deadline above, then its writes
125
126
  * 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.
127
+ * eight calls: two body reads (the compare-and-swap), a body edit and one retry, one
128
+ * read-back, then the comment. A new comment is a post and one retry (seven in all).
129
+ * An edit of the viewer's own comment is the edit, one retry and one read of the
130
+ * comment (eight in all). A scrub is bounded by its own timeout, not by the deadline,
131
+ * so one that starts just before the writes' deadline can end up to REDACT_TIMEOUT_MS
132
+ * after it. The worst case is 90 s + 60 s + 20 s; the caller's 180 s Bash timeout
133
+ * covers it with room for node's startup.
131
134
  */
132
135
  const PUBLISH_DEADLINE_MS = 60000;
133
136
  const PUBLISH_GH_CALLS = 8;
@@ -151,9 +154,10 @@ const PR_VIEW_MAX_BUFFER = 64 * 1024 * 1024;
151
154
  const RUN_LIST_MAX_BUFFER = 1024 * 1024;
152
155
  const RUN_VIEW_MAX_BUFFER = 65536;
153
156
  const LINE_MAX_BUFFER = 4096;
157
+ /** A comment read back as a JSON string: 65,536 characters, each escaped to six, fit. */
158
+ const COMMENT_READ_MAX_BUFFER = 8 * 65536;
154
159
  /** `gh pr checks` and the thread-count GraphQL answer: a thousand rows of names fit. */
155
160
  const READINESS_MAX_BUFFER = 4 * 1024 * 1024;
156
- const READINESS_MAX_CHECKS = 1000;
157
161
  /** 5,000 paths of 4,096 bytes overflow this; an overflow reads as an unresolved diff. */
158
162
  const DIFF_MAX_BUFFER = 16 * 1024 * 1024;
159
163
  const FETCH_MAX_BUFFER = 1024 * 1024;
@@ -173,6 +177,14 @@ const PR_FIELDS = 'body,comments,reviews,author,headRefOid,baseRefOid,isCrossRep
173
177
  const PR_RE = /^[1-9][0-9]{0,9}$/;
174
178
  const SHA40_RE = /^[0-9a-f]{40}$/;
175
179
 
180
+ /**
181
+ * A pull-request comment's url, anchored: the pull number, then the numeric id in the
182
+ * fragment. The host is not pinned (GitHub Enterprise Server hosts work). There is no
183
+ * `m` flag, so `$` is the end of the string and a trailing newline is refused.
184
+ */
185
+ const COMMENT_URL_RE = /^https:\/\/[^/\s?#]+\/[^/\s?#]+\/[^/\s?#]+\/pull\/([1-9][0-9]{0,9})#issuecomment-([1-9][0-9]{0,19})$/;
186
+ /** A comment id as a string of digits: up to 20, so an id beyond 2^53 stays exact. */
187
+ const COMMENT_ID_RE = /^[1-9][0-9]{0,19}$/;
176
188
  /** A review state that decides a login's position on the PR (D6). */
177
189
  const DECISIVE_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED']);
178
190
 
@@ -215,7 +227,8 @@ const USAGE = [
215
227
  * summary, never input bytes. `ghCap` replaces CAPS.GH_CALLS for a stage that
216
228
  * carries a budget of its own (`publish`'s writes).
217
229
  *
218
- * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean }} PrComment
230
+ * @typedef {{ login: string, association: string, body: string, viewerDidAuthor: boolean, commentId: string }} PrComment
231
+ * `commentId` is the comment's numeric id as digits, or '' when its url did not parse.
219
232
  * @typedef {{ login: string, association: string, state: string }} PrReview
220
233
  * @typedef {{ number: number, head: string, base: string, body: string, author: string,
221
234
  * isCrossRepository: boolean | undefined, comments: readonly PrComment[],
@@ -625,6 +638,19 @@ function loginArg(login) {
625
638
  return login;
626
639
  }
627
640
 
641
+ /**
642
+ * Assert a comment id before it reaches gh's argv (an API path segment):
643
+ * COMMENT_ID_RE, so no `/`, `..`, `?` or option can ride on PR data. commentIdOf
644
+ * only ever yields such ids; this is that precondition held at the sink.
645
+ *
646
+ * @param {string} id
647
+ * @returns {string}
648
+ */
649
+ function commentIdArg(id) {
650
+ if (typeof id !== 'string' || !COMMENT_ID_RE.test(id)) throw new Error('verify-evidence: unchecked comment id');
651
+ return id;
652
+ }
653
+
628
654
  // ---------------------------------------------------------------------------
629
655
  // The PR (one gh pr view)
630
656
  // ---------------------------------------------------------------------------
@@ -646,6 +672,21 @@ function loginOf(author) {
646
672
  return typeof login === 'string' && login.length <= 100 ? login : '';
647
673
  }
648
674
 
675
+ /**
676
+ * The numeric id of a PR comment, read from its url, as the digits themselves:
677
+ * never Number(), so an id beyond 2^53 stays exact. '' unless the url is a bounded
678
+ * string of the anchored pull-comment form AND names the PR that was read.
679
+ *
680
+ * @param {unknown} url
681
+ * @param {number} pr
682
+ * @returns {string}
683
+ */
684
+ function commentIdOf(url, pr) {
685
+ if (typeof url !== 'string' || url.length > MAX_URL_CHARS) return '';
686
+ const m = COMMENT_URL_RE.exec(url);
687
+ return m !== null && m[1] === String(pr) ? m[2] : '';
688
+ }
689
+
649
690
  /**
650
691
  * Parse the `gh pr view --json` answer into PrFacts, or null when it is not the
651
692
  * PR that was asked for or a field has the wrong shape. Malformed list entries
@@ -671,6 +712,7 @@ function parsePrJson(json, pr) {
671
712
  association: typeof x.authorAssociation === 'string' ? x.authorAssociation : '',
672
713
  body: typeof x.body === 'string' ? x.body : '',
673
714
  viewerDidAuthor: x.viewerDidAuthor === true,
715
+ commentId: commentIdOf(x.url, pr),
674
716
  });
675
717
  });
676
718
  const reviews = o.reviews.slice(-CAPS.REVIEWS).map(r => {
@@ -751,6 +793,38 @@ function markerComments(pr) {
751
793
  return pr.comments.filter(c => PE.MARKERS.EVIDENCE_RE.test(firstLine(c.body))).reverse();
752
794
  }
753
795
 
796
+ /**
797
+ * @typedef {{ id: string, marker: string, editable: boolean }} EditTarget
798
+ * `id` is the comment's id ('' when unreadable); `marker` is its first line;
799
+ * `editable` is false when a later comment from another login is itself an
800
+ * evidence marker.
801
+ */
802
+
803
+ /**
804
+ * The comment a publish would edit in place: the viewer's NEWEST comment whose first
805
+ * line starts with the evidence head prefix, whatever its key. "The viewer's" is the
806
+ * server-asserted `viewerDidAuthor` flag, so only an own comment is ever a target
807
+ * (never another login's comment, such as a maintainer's or a bot's). A malformed own
808
+ * comment (the prefix alone) is a valid target, so an edit repairs it.
809
+ *
810
+ * `editable` guards the trust order: verify trusts the newest evidence comment by
811
+ * list position, and an edit keeps its old position, so editing is safe only when
812
+ * no strict marker comment from another login comes after the target. A later
813
+ * prefix-only comment from another login is not a record and does not block. The
814
+ * strict trust functions (markerComments, resolveTrust, findTrustedRecord) stay as
815
+ * they are.
816
+ *
817
+ * @param {{ comments: readonly PrComment[] }} pr chronological, newest last
818
+ * @returns {EditTarget | null}
819
+ */
820
+ function editTarget(pr) {
821
+ const at = pr.comments.findLastIndex(c => c.viewerDidAuthor && firstLine(c.body).startsWith(PE.MARKERS.EVIDENCE_HEAD_PREFIX));
822
+ if (at === -1) return null;
823
+ const c = pr.comments[at];
824
+ const editable = !pr.comments.slice(at + 1).some(x => !x.viewerDidAuthor && PE.MARKERS.EVIDENCE_RE.test(firstLine(x.body)));
825
+ return Object.freeze({ id: c.commentId, marker: firstLine(c.body), editable });
826
+ }
827
+
754
828
  /**
755
829
  * D-VERIFY-APPROVAL (D6): each login's LATEST decisive review — APPROVED,
756
830
  * CHANGES_REQUESTED or DISMISSED — in chronological order.
@@ -1437,22 +1511,24 @@ function bodyUpdate(io, plan, ev, body) {
1437
1511
 
1438
1512
  /**
1439
1513
  * 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.
1514
+ * viewer's own NEWEST evidence comment (the edit target) whose first line is this
1515
+ * exact marker (head and key) counts, so a spoofed marker never suppresses a post
1516
+ * and an older exact match behind a newer own comment of another key reads `no`
1517
+ * (the newer one is what a publish edits). `off`, or nothing to record, is no
1518
+ * comment (`n/a`). Null when it fails to render.
1443
1519
  *
1444
1520
  * @param {{ tps: readonly any[] }} plan
1445
1521
  * @param {any} ev
1446
1522
  * @param {'full' | 'off' | 'stub'} mode
1447
- * @param {PrFacts} pr
1523
+ * @param {EditTarget | null} target
1448
1524
  * @returns {{ text: string, posted: 'yes' | 'no' | 'n/a' } | null}
1449
1525
  */
1450
- function commentUpdate(plan, ev, mode, pr) {
1526
+ function commentUpdate(plan, ev, mode, target) {
1451
1527
  if (mode === 'off' || (ev.records.length === 0 && ev.exceptions.length === 0)) return { text: '', posted: 'n/a' };
1452
1528
  const rendered = PE.render(plan, ev, mode);
1453
1529
  if (!rendered.ok) return null;
1454
1530
  const marker = firstLine(rendered.value.text);
1455
- const posted = pr.comments.some(c => c.viewerDidAuthor && firstLine(c.body) === marker) ? 'yes' : 'no';
1531
+ const posted = target !== null && target.marker === marker ? 'yes' : 'no';
1456
1532
  return { text: rendered.value.text, posted };
1457
1533
  }
1458
1534
 
@@ -1491,25 +1567,44 @@ function writeOutputs(args, body, blockText, commentText, staleText) {
1491
1567
  return writes.every(([file, text]) => file === null || writeTextFile(file, text));
1492
1568
  }
1493
1569
 
1570
+ /**
1571
+ * @typedef {{ outcome: Outcome, target: EditTarget | null }} VerifyPass
1572
+ * The verify result and the viewer's edit target from the same PR snapshot (null on
1573
+ * every refusal). The target stays in memory: it never reaches stdout, a flag or a file.
1574
+ */
1575
+
1494
1576
  /**
1577
+ * @param {Io} io
1578
+ * @param {number} code
1579
+ * @param {string} reason a fixed phrase from this file, never input bytes
1580
+ * @returns {VerifyPass}
1581
+ */
1582
+ function refusedPass(io, code, reason) {
1583
+ return { outcome: refuse(io, code, reason), target: null };
1584
+ }
1585
+
1586
+ /**
1587
+ * One verify pass: the Outcome the `verify` subcommand prints, and the edit target
1588
+ * the same snapshot gave. `posted` in the EVIDENCE line is taken from that target.
1589
+ *
1495
1590
  * @param {Io} io
1496
1591
  * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1497
1592
  * @param {MainDeps} deps
1498
- * @returns {Outcome}
1593
+ * @returns {VerifyPass}
1499
1594
  */
1500
- function runVerify(io, args, deps) {
1595
+ function verifyPass(io, args, deps) {
1501
1596
  // Inputs first: an unusable evidence file or state directory decides before any call.
1502
1597
  /** @type {EvidenceInput | null} */
1503
1598
  let evidence = null;
1504
1599
  if (args.evidence !== null) {
1505
1600
  evidence = loadEvidence(args.evidence);
1506
- if (evidence === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
1601
+ if (evidence === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'evidence file unusable');
1507
1602
  if (evidence.malformed > 0) io.notes.add('malformed-claims');
1508
1603
  }
1509
- if (args.state !== null && !isRealDirectory(args.state)) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1604
+ if (args.state !== null && !isRealDirectory(args.state)) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'state directory unusable');
1510
1605
 
1511
1606
  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');
1607
+ if (pr === null) return refusedPass(io, EXIT_CODES.REMOTE_FAILURE, 'the PR could not be read');
1513
1608
 
1514
1609
  const trust = resolveTrust(io, pr, args.approval);
1515
1610
  const record = findTrustedRecord(pr, trust);
@@ -1520,12 +1615,12 @@ function runVerify(io, args, deps) {
1520
1615
  // trusted record's.
1521
1616
  const fromFile = evidence !== null && evidence.plan !== null;
1522
1617
  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');
1618
+ if (plan === null) return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block cannot serve as the plan');
1524
1619
  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');
1620
+ return refusedPass(io, EXIT_CODES.INPUT_UNUSABLE, 'the PR body test-plan block drops a TP the trusted record carries');
1526
1621
  }
1527
1622
  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');
1623
+ if (inputs === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'a TP line failed its grammar');
1529
1624
 
1530
1625
  const repo = prepareRepo(io, pr);
1531
1626
  /** @type {RunMemo} */
@@ -1539,7 +1634,7 @@ function runVerify(io, args, deps) {
1539
1634
  : record.kind === 'record' ? record.exceptions : [];
1540
1635
  const key = PE.dedupeKey({ records, exceptions }, sha256);
1541
1636
  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');
1637
+ if (!key.ok || !tally.ok) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the records failed their grammar');
1543
1638
 
1544
1639
  const mode = publicationMode(args.publication);
1545
1640
  const ev = {
@@ -1550,8 +1645,9 @@ function runVerify(io, args, deps) {
1550
1645
  exceptions,
1551
1646
  };
1552
1647
  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');
1648
+ const target = editTarget(pr);
1649
+ const comment = commentUpdate(plan, ev, mode, target);
1650
+ if (body === null || comment === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the block or the comment failed to render');
1555
1651
 
1556
1652
  const line = gateEvidenceLine(deps, {
1557
1653
  pr: pr.number,
@@ -1565,12 +1661,22 @@ function runVerify(io, args, deps) {
1565
1661
  posted: comment.posted,
1566
1662
  body: body.changed ? 'changed' : 'same',
1567
1663
  }, plan.tps.length);
1568
- if (line === null) return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1664
+ if (line === null) return refusedPass(io, EXIT_CODES.OUTPUT_GATE_REFUSED, 'the EVIDENCE line failed its gate');
1569
1665
 
1570
1666
  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');
1667
+ return refusedPass(io, EXIT_CODES.WRITE_FAILED, 'an output file could not be written');
1572
1668
  }
1573
- return { code: EXIT_CODES.OK, stdout: line + '\n' };
1669
+ return { outcome: { code: EXIT_CODES.OK, stdout: line + '\n' }, target };
1670
+ }
1671
+
1672
+ /**
1673
+ * @param {Io} io
1674
+ * @param {Extract<ParsedArgs, { kind: 'verify' }>} args
1675
+ * @param {MainDeps} deps
1676
+ * @returns {Outcome}
1677
+ */
1678
+ function runVerify(io, args, deps) {
1679
+ return verifyPass(io, args, deps).outcome;
1574
1680
  }
1575
1681
 
1576
1682
  /**
@@ -1755,7 +1861,7 @@ const EVIDENCE_UNAVAILABLE_LINE = 'TRACEABILITY: DEGRADED (evidence unavailable)
1755
1861
  */
1756
1862
 
1757
1863
  const BODY_STATUSES = Object.freeze(['EDITED', 'UNCHANGED', 'SKIPPED']);
1758
- const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF']);
1864
+ const COMMENT_STATUSES = Object.freeze(['POSTED', 'SKIPPED', 'OFF', 'EDITED']);
1759
1865
 
1760
1866
  /**
1761
1867
  * @param {string} status
@@ -1900,6 +2006,18 @@ function ghWrite(io, args) {
1900
2006
  return { ok: r.ok, serverError: isServerError(r) };
1901
2007
  }
1902
2008
 
2009
+ /**
2010
+ * The DEGRADED part for a gh write that failed: the 5xx phrase after a server error,
2011
+ * else the phrase of the failed step. It runs no gh call.
2012
+ *
2013
+ * @param {{ ok: boolean, serverError: boolean }} result what ghWrite returned
2014
+ * @param {string} reason a fixed phrase from this file
2015
+ * @returns {Part}
2016
+ */
2017
+ function writeFailedPart(result, reason) {
2018
+ return degradedPart(result.serverError ? '5xx on update-pr-evidence' : reason);
2019
+ }
2020
+
1903
2021
  /**
1904
2022
  * The context for the writes: a gh budget and a deadline of their own, the same
1905
2023
  * notes. The throttle flag carries over, so a rate limit that verify hit stops the
@@ -1938,38 +2056,77 @@ function publishBody(io, pr, fields, files) {
1938
2056
  if (outcome === 'conflict') return okPart('SKIPPED');
1939
2057
  if (outcome !== 'ok' && outcome !== 'resplice') return degradedPart('splice ' + outcome);
1940
2058
  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');
2059
+ if (!edit.ok) return writeFailedPart(edit, 'gh pr edit failed');
1942
2060
  const back = runReadback(io, { kind: 'readback', pr, expect: files.body });
1943
2061
  return back.code === EXIT_CODES.OK && back.stdout === 'READBACK ok\n' ? okPart('EDITED') : degradedPart('body read-back mismatch');
1944
2062
  }
1945
2063
 
1946
2064
  /**
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.
2065
+ * Whether the comment just edited now reads back as the scrubbed text: one GET of it
2066
+ * as a JSON string, compared after one trailing newline is normalised on each side.
2067
+ * The edit itself runs silent, so this is the only read of the comment. Any failure
2068
+ * to read or parse is "no". Neither the comment nor gh's stderr is printed.
2069
+ *
2070
+ * @param {Io} io the write-stage context
2071
+ * @param {string} id a comment id commentIdOf produced
2072
+ * @param {string} expected the scrubbed comment text (never the unscrubbed file)
2073
+ * @returns {boolean}
2074
+ */
2075
+ function commentReadsBack(io, id, expected) {
2076
+ const r = gh(io, ['api', 'repos/{owner}/{repo}/issues/comments/' + commentIdArg(id), '--jq', '.body | @json'], COMMENT_READ_MAX_BUFFER);
2077
+ if (!r.ok) return false;
2078
+ try {
2079
+ const body = JSON.parse(stripOneNewline(r.stdout.toString('utf8')));
2080
+ return typeof body === 'string' && stripOneNewline(body) === stripOneNewline(expected);
2081
+ } catch (_) {
2082
+ return false;
2083
+ }
2084
+ }
2085
+
2086
+ /**
2087
+ * The comment half: the evidence comment, scrubbed (D11) and re-measured after the
2088
+ * scrub. When the viewer's own evidence comment is a safe target (no strict marker
2089
+ * from another login follows it), that comment is edited in place (one PATCH of the
2090
+ * scrubbed file) and read back against it. Otherwise a new comment is posted once.
2091
+ * A comment that already carries this head and key is skipped. An own target with no
2092
+ * readable id is DEGRADED before the scrub, with no write. A failed edit or
2093
+ * read-back is a DEGRADED result and never falls back to a post. Nothing is deleted.
1949
2094
  *
1950
2095
  * @param {Io} io the write-stage context
1951
2096
  * @param {number} pr
1952
2097
  * @param {{ posted: string }} fields the EVIDENCE line's fields
1953
2098
  * @param {{ commentRaw: string, comment: string }} files
2099
+ * @param {EditTarget | null} target the viewer's newest evidence comment in the snapshot
2100
+ * verify read, or null when there is none
1954
2101
  * @returns {Part}
1955
2102
  */
1956
- function publishComment(io, pr, fields, files) {
2103
+ function publishComment(io, pr, fields, files, target) {
1957
2104
  if (fields.posted === 'yes') return okPart('SKIPPED');
1958
2105
  if (fields.posted === 'n/a') return okPart('OFF');
2106
+ const edit = target !== null && target.editable;
2107
+ if (edit && target.id === '') return degradedPart('comment id unreadable');
1959
2108
  if (!scrub(io, files.commentRaw, files.comment)) return degradedPart('redaction unavailable');
1960
2109
  const text = readTextFile(files.comment);
1961
2110
  if (text === null || text === '') return degradedPart('redaction unavailable');
1962
2111
  if (text.length > COMMENT_POST_MAX_CHARS) return degradedPart('comment over size cap');
2112
+ if (edit) {
2113
+ const patch = ghWrite(io, ['api', '--method', 'PATCH',
2114
+ 'repos/{owner}/{repo}/issues/comments/' + commentIdArg(target.id),
2115
+ '-F', 'body=@' + files.comment, '--silent']);
2116
+ if (!patch.ok) return writeFailedPart(patch, 'comment edit failed');
2117
+ return commentReadsBack(io, target.id, text) ? okPart('EDITED') : degradedPart('comment read-back mismatch');
2118
+ }
1963
2119
  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');
2120
+ return post.ok ? okPart('POSTED') : writeFailedPart(post, 'gh pr comment failed');
1966
2121
  }
1967
2122
 
1968
2123
  /**
1969
2124
  * The whole update-pr-evidence operation, in the order the operation always had:
1970
2125
  * 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.
2126
+ * `posted:no` scrub, then edit the viewer's own evidence comment in place and read it
2127
+ * back, or post a new one when there is none to edit or a newer evidence comment from
2128
+ * another login follows it. The work files live in one private directory this call
2129
+ * makes and removes.
1973
2130
  *
1974
2131
  * @param {Io} io
1975
2132
  * @param {Extract<ParsedArgs, { kind: 'publish' }>} args
@@ -2000,18 +2157,18 @@ function runPublish(io, args, deps) {
2000
2157
  commentRaw: file('comment.raw'),
2001
2158
  comment: file('comment'),
2002
2159
  };
2003
- const verified = runVerify(io, {
2160
+ const verified = verifyPass(io, {
2004
2161
  kind: 'verify', pr: args.pr, publication: args.publication, evidence: args.evidence, state,
2005
2162
  blockOut: files.notesRaw, commentOut: files.commentRaw, staleOut: null, approval: false,
2006
2163
  }, deps);
2007
- if (verified.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
2008
- const line = stripOneNewline(verified.stdout);
2164
+ if (verified.outcome.code !== EXIT_CODES.OK) return evidenceUnavailable(io, 'verify did not answer');
2165
+ const line = stripOneNewline(verified.outcome.stdout);
2009
2166
  const parsed = PE.parseEvidenceLine(line);
2010
2167
  if (!parsed.ok || parsed.value.pr !== args.pr) return evidenceUnavailable(io, 'verify answered something else');
2011
2168
 
2012
2169
  const stage = writeStageIo(io);
2013
2170
  const body = publishBody(stage, args.pr, parsed.value, files);
2014
- const comment = publishComment(stage, args.pr, parsed.value, files);
2171
+ const comment = publishComment(stage, args.pr, parsed.value, files, verified.target);
2015
2172
  return { code: EXIT_CODES.OK, stdout: renderPublishBlock(line, body, comment) };
2016
2173
  } finally {
2017
2174
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* the OS clears its temp directory */ }
@@ -2162,21 +2319,28 @@ function parseJsonOrNull(text) {
2162
2319
  }
2163
2320
 
2164
2321
  /**
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.
2322
+ * The PR's review decision, merge state and head, from one `gh pr view` read. The
2323
+ * decision answers `""` or null when no rule asks for a review; a string outside the
2324
+ * three documented values reads UNRECOGNISED, and the ladder never lets that reach READY.
2325
+ * `mergeable` is the string GitHub printed, or null when the key is absent or not a
2326
+ * string; `head` is the PR head when it is 40 lowercase hex, else null. Neither makes the
2327
+ * decision unreadable: the CI read treats them as unknown and never as a pass.
2168
2328
  *
2169
2329
  * @param {Io} io
2170
2330
  * @param {number} pr
2171
- * @returns {Read<string | null>}
2331
+ * @returns {Read<{ decision: string | null, mergeable: string | null, head: string | null }>}
2172
2332
  */
2173
- function readDecision(io, pr) {
2174
- const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision'], LINE_MAX_BUFFER);
2333
+ function readPrView(io, pr) {
2334
+ const r = gh(io, ['pr', 'view', String(pr), '--json', 'reviewDecision,mergeable,headRefOid'], LINE_MAX_BUFFER);
2175
2335
  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 };
2336
+ const fields = obj(parseJsonOrNull(r.stdout.toString('utf8')));
2337
+ const value = fields.reviewDecision;
2338
+ const mergeable = typeof fields.mergeable === 'string' ? fields.mergeable : null;
2339
+ const head = typeof fields.headRefOid === 'string' && SHA40_RE.test(fields.headRefOid) ? fields.headRefOid : null;
2340
+ if (value === null || value === '') return { ok: true, value: { decision: null, mergeable, head } };
2178
2341
  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' };
2342
+ const decision = ['APPROVED', 'CHANGES_REQUESTED', 'REVIEW_REQUIRED'].includes(value) ? value : 'UNRECOGNISED';
2343
+ return { ok: true, value: { decision, mergeable, head } };
2180
2344
  }
2181
2345
 
2182
2346
  /**
@@ -2215,37 +2379,91 @@ function readThreads(io, pr) {
2215
2379
  return { ok: true, value: { unresolved, approximate: total > THREADS_PAGE } };
2216
2380
  }
2217
2381
 
2382
+ /** CI that could not be established: the ladder reads it as `status unknown`, never READY. */
2383
+ const CI_INDETERMINATE = Object.freeze({ status: 'INDETERMINATE', failing: '-' });
2384
+
2385
+ /**
2386
+ * D-READINESS-WORKFLOWS: whether the repository has an active workflow file, by ci-wait's
2387
+ * one read-only call and parser. A call that fails or is refused, and a reply the parser
2388
+ * cannot read, give null: not knowing, never "no workflow file".
2389
+ *
2390
+ * @param {Io} io
2391
+ * @returns {boolean | null}
2392
+ */
2393
+ function probeWorkflows(io) {
2394
+ const r = gh(io, CW.WORKFLOWS_ARGS, READINESS_MAX_BUFFER);
2395
+ return r.ok ? CW.parseActiveWorkflows(r.stdout.toString('utf8')) : null;
2396
+ }
2397
+
2398
+ /**
2399
+ * An empty check list: ci-wait's merge gate decides (D-CI-WORKFLOWS). The workflow list
2400
+ * is asked only when the gate asks for it, so a PR that cannot be NO_CI costs no call. A
2401
+ * gate answer other than keep is INDETERMINATE.
2402
+ *
2403
+ * @param {Io} io
2404
+ * @param {string | null} mergeable
2405
+ * @returns {{ status: string, failing: string }}
2406
+ */
2407
+ function readEmptyCi(io, mergeable) {
2408
+ const first = CW.mergeGate('NO_CI', mergeable);
2409
+ const gate = first === 'probe' ? CW.mergeGate('NO_CI', mergeable, probeWorkflows(io)) : first;
2410
+ return gate === 'keep' ? { status: 'NO_CI', failing: '-' } : CI_INDETERMINATE;
2411
+ }
2412
+
2218
2413
  /**
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.
2414
+ * D-READINESS-CLASSIFIER: the CI status, read once. The checks come from `gh pr checks`
2415
+ * over name,state,bucket,workflow, with check-ci-status's exit rule (exit 0 or 8 is a
2416
+ * result; any other failure is INDETERMINATE). The rows come from ci-wait's parser, and the
2417
+ * status from ci-wait's classifier (D-CI-WORKFLOW-RULE) and merge gate (D-CI-MERGE-STATE,
2418
+ * D-CI-WORKFLOWS), where the rules are written, so the two scripts cannot disagree. A
2419
+ * conflicting PR reads INDETERMINATE before any checks call, and "no checks reported" or
2420
+ * `[]` goes to readEmptyCi. An unknown merge state, a failed workflow-list call and an
2421
+ * unreadable reply are INDETERMINATE, never DEGRADED. Failing names are sanitized and
2422
+ * bounded by ci-wait's own formatter. PASSING does not prove that the test workflow ran by
2423
+ * name.
2223
2424
  *
2224
2425
  * @param {Io} io
2225
2426
  * @param {number} pr
2427
+ * @param {string | null} mergeable GitHub's merge state of the PR, from readPrView
2226
2428
  * @returns {{ status: string, failing: string }}
2227
2429
  */
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;
2430
+ function readCi(io, pr, mergeable) {
2431
+ if (CW.isConflicting(mergeable)) return CI_INDETERMINATE;
2432
+ const r = gh(io, ['pr', 'checks', String(pr), '--json', 'name,state,bucket,workflow'], READINESS_MAX_BUFFER);
2433
+ if (!answered(r)) return CI_INDETERMINATE;
2232
2434
  if (r.status !== 0 && r.status !== 8) {
2233
2435
  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 });
2436
+ return /no checks reported/i.test(r.stderr) ? readEmptyCi(io, mergeable) : CI_INDETERMINATE;
2244
2437
  }
2438
+ const checks = CW.parseChecks(r.stdout.toString('utf8'));
2439
+ if (checks === null) return CI_INDETERMINATE;
2440
+ if (checks.length === 0) return readEmptyCi(io, mergeable);
2245
2441
  const c = CW.classifyChecks(checks);
2442
+ if (CW.mergeGate(c.status, mergeable, null) !== 'keep') return CI_INDETERMINATE;
2246
2443
  return { status: c.status, failing: CW.formatNames(c.failing) };
2247
2444
  }
2248
2445
 
2446
+ /**
2447
+ * D-READINESS-HEAD: CI counts for one head. `gh pr checks` answers for whatever head the
2448
+ * PR has when it is called, and the evidence is read after it, so a push in between would
2449
+ * pair a CI result with evidence of another head. PASSING and NO_CI stand only when the
2450
+ * head read with the review decision, before the checks call, and the head the evidence
2451
+ * was read at are both 40 lowercase hex and equal; otherwise CI is INDETERMINATE. FAILING,
2452
+ * PENDING and the rest stay: they hold the ladder back on their own.
2453
+ *
2454
+ * @param {{ status: string, failing: string }} ci
2455
+ * @param {unknown} viewHead the head read before the checks call, or null
2456
+ * @param {unknown} evidenceHead the head the evidence was read at, or null when it is unknown
2457
+ * @returns {{ status: string, failing: string }}
2458
+ */
2459
+ function bindCiHead(ci, viewHead, evidenceHead) {
2460
+ if (ci.status !== 'PASSING' && ci.status !== 'NO_CI') return ci;
2461
+ /** @param {unknown} head */
2462
+ const isHead = head => typeof head === 'string' && SHA40_RE.test(head);
2463
+ const bound = isHead(viewHead) && isHead(evidenceHead) && viewHead === evidenceHead;
2464
+ return bound ? ci : CI_INDETERMINATE;
2465
+ }
2466
+
2249
2467
  /**
2250
2468
  * The test-plan evidence at the head, by `verify --approval`: known only on exit 0
2251
2469
  * with one EVIDENCE line for this PR (the old step 4's rule).
@@ -2275,6 +2493,15 @@ function approvalText(require, evidence) {
2275
2493
  return evidence !== null && evidence.approval === 'yes' ? 'yes' : 'no';
2276
2494
  }
2277
2495
 
2496
+ /**
2497
+ * @param {string | null} decision the review decision from readPrView
2498
+ * @returns {string} the word the Details line prints for it
2499
+ */
2500
+ function decisionLabel(decision) {
2501
+ if (decision === null) return 'none';
2502
+ return decision === 'UNRECOGNISED' ? 'unrecognised' : decision;
2503
+ }
2504
+
2278
2505
  /**
2279
2506
  * A readiness answer that could not be reached: a DEGRADED block (exit 2) whose Details
2280
2507
  * say `unavailable` for what was never read.
@@ -2308,15 +2535,17 @@ function readinessDegraded(pr, reason, known) {
2308
2535
  */
2309
2536
  function runReadiness(io, args, deps) {
2310
2537
  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;
2538
+ const view = readPrView(io, args.pr);
2539
+ if (!view.ok) return readinessDegraded(args.pr, view.reason, { approval: unreadApproval });
2540
+ const reviewDecision = view.value.decision;
2541
+ const decisionText = decisionLabel(reviewDecision);
2314
2542
  const threads = readThreads(io, args.pr);
2315
2543
  if (!threads.ok) {
2316
2544
  return readinessDegraded(args.pr, threads.reason, { decision: decisionText, approval: unreadApproval });
2317
2545
  }
2318
- const ci = readCi(io, args.pr);
2546
+ const ciRead = readCi(io, args.pr, view.value.mergeable);
2319
2547
  const fields = readEvidence(io, args.pr, deps);
2548
+ const ci = bindCiHead(ciRead, view.value.head, fields === null ? null : fields.head);
2320
2549
  /** @type {EvidenceFacts | null} */
2321
2550
  const evidence = fields === null ? null : {
2322
2551
  total: fields.total,
@@ -2328,7 +2557,7 @@ function runReadiness(io, args, deps) {
2328
2557
  const verdict = readinessVerdict({
2329
2558
  unresolved: threads.value.unresolved,
2330
2559
  approximate: threads.value.approximate,
2331
- decision: decision.value,
2560
+ decision: reviewDecision,
2332
2561
  ci: ci.status,
2333
2562
  failing: ci.failing,
2334
2563
  evidence,
@@ -2532,8 +2761,11 @@ module.exports = Object.freeze({
2532
2761
  BODY_STATUSES,
2533
2762
  COMMENT_STATUSES,
2534
2763
  readinessVerdict,
2764
+ bindCiHead,
2535
2765
  renderPublishBlock,
2536
2766
  parsePublishBlock,
2537
2767
  renderReadinessBlock,
2538
2768
  parseReadinessBlock,
2769
+ commentIdOf,
2770
+ editTarget,
2539
2771
  });