forge-workflow 0.1.0-beta.2 → 0.1.0-beta.3

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 (57) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +1 -0
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge.js +90 -19
  8. package/docs/guides/SETUP.md +4 -1
  9. package/docs/guides/SUPPORT.md +5 -0
  10. package/docs/reference/COMMANDS.md +9 -0
  11. package/docs/reference/shepherd.md +42 -2
  12. package/lib/activation/ensure-forge-home.js +135 -0
  13. package/lib/adapters/beads-kernel-compat.js +67 -0
  14. package/lib/adoption-profiles.js +17 -4
  15. package/lib/beads-detect.js +60 -0
  16. package/lib/beads-nudge.js +91 -0
  17. package/lib/commands/_aliases.js +248 -0
  18. package/lib/commands/_issue.js +39 -0
  19. package/lib/commands/_manifest.js +2 -0
  20. package/lib/commands/_registry.js +14 -0
  21. package/lib/commands/_resolve-command-opts.js +0 -31
  22. package/lib/commands/gate.js +19 -2
  23. package/lib/commands/hooks.js +139 -4
  24. package/lib/commands/init.js +26 -20
  25. package/lib/commands/memory.js +81 -0
  26. package/lib/commands/migrate.js +0 -161
  27. package/lib/commands/plan.js +48 -8
  28. package/lib/commands/pr.js +88 -0
  29. package/lib/commands/push.js +66 -0
  30. package/lib/commands/recall.js +67 -12
  31. package/lib/commands/recap.js +18 -4
  32. package/lib/commands/release.js +14 -1
  33. package/lib/commands/remember.js +86 -20
  34. package/lib/commands/setup.js +135 -72
  35. package/lib/commands/shepherd.js +67 -2
  36. package/lib/commands/ship.js +40 -4
  37. package/lib/commands/worktree.js +60 -4
  38. package/lib/core/runtime-graph.js +34 -3
  39. package/lib/gate-events.js +54 -55
  40. package/lib/global-flags.js +30 -0
  41. package/lib/grounding/context-events.js +230 -0
  42. package/lib/grounding/read-first.js +112 -0
  43. package/lib/hook-renderer.js +93 -3
  44. package/lib/kernel/backing-issue.js +7 -1
  45. package/lib/kernel/owned-kernel.js +43 -0
  46. package/lib/kernel/sqlite-driver.js +37 -1
  47. package/lib/pr-monitor/auto-actions.js +175 -0
  48. package/lib/pr-monitor/digest.js +206 -0
  49. package/lib/pr-monitor/render-sticky.js +43 -8
  50. package/lib/pr-monitor/upsert-sticky.js +169 -0
  51. package/lib/pr-pull.js +43 -2
  52. package/lib/release-readiness.js +17 -1
  53. package/lib/upgrade-safety.js +53 -1
  54. package/lib/workflow/enforce-stage.js +59 -2
  55. package/package.json +2 -2
  56. package/scripts/pr-auto-actions.js +93 -0
  57. package/scripts/pr-verdict-label.js +50 -0
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor Tier-2 auto-actions — the SAFE, fail-closed *action* half of the
5
+ * shepherd (issue addf5297, epic c2d398e5). Tier-1 (lib/pr-pull.js computeVerdict
6
+ * + the pr-monitor workflow) LABELS a PR's state but takes no action, so PRs that
7
+ * only need "master merged in" sit untended until a human/agent nudges them.
8
+ *
9
+ * This module decides — PURELY, from the SAME `forge shepherd <pr> --pull --json`
10
+ * payload the monitor already computes — whether the monitor may take one of two
11
+ * surface-safe actions:
12
+ * 1. `updateBranch` — merge base into an OTHERWISE-CLEAN-but-BEHIND PR (the
13
+ * "last mile" case). This is the highest-value, safest action: it clears the
14
+ * BEHIND churn without ever touching a PR that has a real blocker.
15
+ * 2. `rerunFlaky` — re-run a required check whose failure is INFRASTRUCTURAL
16
+ * (cancelled / timed-out / stale / startup-failure), never a real test
17
+ * FAILURE/ERROR.
18
+ *
19
+ * It NEVER merges, NEVER resolves review threads, NEVER force-pushes, NEVER edits
20
+ * code. It only decides; the workflow executes the `gh` calls (and owns
21
+ * per-head-SHA idempotency markers). Every gate below is fail-CLOSED: any missing
22
+ * field, degraded read, real failure, fork, draft, or unclassifiable signal
23
+ * yields `should:false`.
24
+ *
25
+ * @module pr-monitor/auto-actions
26
+ */
27
+
28
+ /**
29
+ * Check conclusions that are INFRASTRUCTURAL flakes — a re-run may legitimately
30
+ * turn them green. Mirrors lib/pr-shepherd.js `isFailed`'s not-green terminal
31
+ * conclusions MINUS the genuinely-broken ones (FAILURE/ERROR/ACTION_REQUIRED).
32
+ */
33
+ const INFRA_CONCLUSIONS = new Set(['CANCELLED', 'TIMED_OUT', 'STALE', 'STARTUP_FAILURE']);
34
+
35
+ /** Conclusions that mean the code is genuinely broken — NEVER auto-rerun these. */
36
+ const REAL_FAILURE_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'ACTION_REQUIRED']);
37
+
38
+ /**
39
+ * Pull the numeric Actions run id from a job/run "details" URL
40
+ * (`.../actions/runs/<run>/job/<job>` or `.../actions/runs/<run>`). Returns null
41
+ * when absent — a null run id fails the rerun decision closed.
42
+ *
43
+ * @param {string} url
44
+ * @returns {string | null}
45
+ */
46
+ function runIdFromUrl(url) {
47
+ const m = String(url || '').match(/\/runs\/(\d+)/);
48
+ return m ? m[1] : null;
49
+ }
50
+
51
+ /**
52
+ * A payload is DEGRADED (some verdict-relevant read failed or the head moved
53
+ * mid-gather) when its evidence lists unreadable sources or a torn read. Acting
54
+ * on a degraded gather could act on stale/false state, so both actions fail
55
+ * closed on it — even though `verdict==='BEHIND'` already implies a clean read,
56
+ * this stays an explicit, independent guard.
57
+ *
58
+ * @param {object} payload
59
+ * @returns {boolean}
60
+ */
61
+ function isDegraded(payload) {
62
+ const ev = (payload && payload.evidence) || {};
63
+ const unreadable = Array.isArray(ev.unreadable) ? ev.unreadable : [];
64
+ return unreadable.length > 0 || ev.tornRead === true;
65
+ }
66
+
67
+ /**
68
+ * Decide whether to auto-update (merge base into) an otherwise-clean-but-BEHIND
69
+ * PR. Fires ONLY for the "last mile" case:
70
+ * - verdict is exactly `BEHIND` (which itself guarantees rank-1 UNKNOWN and
71
+ * rank-2 BLOCKED-CONFLICT did NOT fire — i.e. the read was clean and there is
72
+ * no conflict);
73
+ * - the PR is NOT a draft and NOT a fork (a base-repo token cannot push a fork
74
+ * branch, and forks are out of scope);
75
+ * - the read is not degraded;
76
+ * - and the ONLY blocker is the behind-base one — every other blocker type
77
+ * (failing/missing/skipped/pending required checks, bot-status gates,
78
+ * unresolved threads, changes-requested / review-required, conflict) is
79
+ * absent. `blockers[]` is computed independently of the verdict precedence,
80
+ * so it still lists lower-precedence blockers that `BEHIND` masks — which is
81
+ * exactly why we key on it rather than on the single verdict string.
82
+ *
83
+ * @param {object} payload - the `--pull --json` payload.
84
+ * @param {{ isFork?: boolean }} [opts]
85
+ * @returns {{ should: boolean, reason: string }}
86
+ */
87
+ function decideUpdateBranch(payload, opts = {}) {
88
+ const skip = (reason) => ({ should: false, reason });
89
+ if (!payload || typeof payload !== 'object') return skip('no payload — fail closed');
90
+ if (opts.isFork) return skip('fork PR — a base-repo token cannot update a fork branch');
91
+ if (isDegraded(payload)) return skip('degraded/torn read — fail closed');
92
+ if (payload.verdict !== 'BEHIND') return skip(`verdict ${payload.verdict || 'UNKNOWN'} is not BEHIND`);
93
+ if (payload.draft === true) return skip('draft PR — not ready to advance');
94
+ if (!Array.isArray(payload.blockers)) return skip('blockers[] unavailable — fail closed');
95
+ const others = payload.blockers.filter((b) => b && b.type !== 'behind');
96
+ if (others.length > 0) {
97
+ return skip(`other blocker(s) present: ${others.map((b) => b.type).join(', ')}`);
98
+ }
99
+ return { should: true, reason: 'otherwise-clean-behind — only blocker is behind-base; merge base in' };
100
+ }
101
+
102
+ /**
103
+ * Decide whether to re-run flaky REQUIRED checks. Fires ONLY when EVERY failing
104
+ * required check is infrastructural (cancelled/timed-out/stale/startup-failure)
105
+ * with a derivable run id, and NONE is a real FAILURE/ERROR/ACTION_REQUIRED. A
106
+ * single real failure, an unclassifiable conclusion, a required-failing check
107
+ * with no matching `failures[]` entry, or a missing run id fails the WHOLE
108
+ * decision closed (never rerun a genuinely-broken PR, never loop on a real bug).
109
+ *
110
+ * @param {object} payload - the `--pull --json` payload.
111
+ * @returns {{ should: boolean, checks: Array<{name:string,conclusion:string,runId:string|null}>, runIds: string[], reason: string }}
112
+ */
113
+ function decideRerun(payload) {
114
+ const empty = (reason) => ({ should: false, checks: [], runIds: [], reason });
115
+ if (!payload || typeof payload !== 'object') return empty('no payload — fail closed');
116
+ if (isDegraded(payload)) return empty('degraded/torn read — fail closed');
117
+
118
+ const rc = payload.requiredChecks || {};
119
+ const failingNames = Array.isArray(rc.failing) ? rc.failing : [];
120
+ if (failingNames.length === 0) return empty('no failing required checks');
121
+
122
+ const failures = Array.isArray(payload.failures) ? payload.failures : [];
123
+ const conclByName = new Map();
124
+ const urlByName = new Map();
125
+ for (const f of failures) {
126
+ if (!f || !f.name) continue;
127
+ if (!conclByName.has(f.name)) {
128
+ conclByName.set(f.name, String(f.conclusion || '').toUpperCase());
129
+ urlByName.set(f.name, f.jobUrl || f.detailsUrl || null);
130
+ }
131
+ }
132
+
133
+ const picked = [];
134
+ for (const name of failingNames) {
135
+ const concl = conclByName.get(name);
136
+ if (!concl) return empty(`required check "${name}" has no known conclusion — cannot confirm flaky, fail closed`);
137
+ if (REAL_FAILURE_CONCLUSIONS.has(concl)) return empty(`required check "${name}" is a real failure (${concl}) — never rerun`);
138
+ if (!INFRA_CONCLUSIONS.has(concl)) return empty(`required check "${name}" conclusion ${concl} is not classified infrastructural — fail closed`);
139
+ picked.push({ name, conclusion: concl, runId: runIdFromUrl(urlByName.get(name)) });
140
+ }
141
+
142
+ const runIds = [...new Set(picked.map((p) => p.runId).filter(Boolean))];
143
+ if (runIds.length === 0) return empty('no run id derivable from failure jobUrl — fail closed');
144
+ return {
145
+ should: true,
146
+ checks: picked,
147
+ runIds,
148
+ reason: `all ${picked.length} failing required check(s) are infrastructural (${picked.map((p) => p.conclusion).join(', ')})`,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Compute the full auto-action decision from a `--pull --json` payload. Pure and
154
+ * independently testable — no I/O, no `gh`, no side effects.
155
+ *
156
+ * @param {object} payload
157
+ * @param {{ isFork?: boolean }} [opts]
158
+ * @returns {{ updateBranch: object, rerunFlaky: object }}
159
+ */
160
+ function decideAutoActions(payload, opts = {}) {
161
+ return {
162
+ updateBranch: decideUpdateBranch(payload, opts),
163
+ rerunFlaky: decideRerun(payload),
164
+ };
165
+ }
166
+
167
+ module.exports = {
168
+ decideAutoActions,
169
+ decideUpdateBranch,
170
+ decideRerun,
171
+ runIdFromUrl,
172
+ isDegraded,
173
+ INFRA_CONCLUSIONS,
174
+ REAL_FAILURE_CONCLUSIONS,
175
+ };
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-shepherd digest — the thin CONSUMER half of the constant monitor (epic
5
+ * c2d398e5, 33e1bbd3). The constant watch loop is the PRODUCER: it writes the
6
+ * per-PR NDJSON journals under `.forge/pr-monitor/<repo>-<pr>/` (the `forge
7
+ * shepherd events` pull surface reads those same records back). Nothing, though,
8
+ * surfaced those events to a working agent. This module is a pure READER: it
9
+ * reads the NEW budget events across all PR journals since a persisted per-PR
10
+ * CONSUMER cursor, renders a COMPACT capped summary, and advances the cursor —
11
+ * the exact payload a harness hook (Claude UserPromptSubmit) injects each turn.
12
+ *
13
+ * The CORE (events/journal/watch/monitor) is untouched: this only READS the
14
+ * journal via `journal.readEventsSince` and keeps its OWN `consumer.cursor`
15
+ * (distinct from the watcher's snapshot), so consumption never disturbs
16
+ * production. Every function is fail-open — a bad journal degrades to skipped,
17
+ * never throws — because it feeds a hook that must never block a prompt.
18
+ *
19
+ * @module pr-monitor/digest
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const journalMod = require('./journal');
26
+
27
+ /**
28
+ * The event types worth surfacing on each turn — the ACTIONABLE transitions
29
+ * (verdict flip, a failed check, a new review thread, terminal merge/close).
30
+ * Everything else (head pushes, green checks, degraded notices) stays in the
31
+ * journal for `forge shepherd events` but is NOT pushed, to keep the injected
32
+ * context tiny.
33
+ */
34
+ const BUDGET_TYPES = Object.freeze(new Set([
35
+ 'verdict.changed', 'check.failed', 'thread.opened', 'pr.merged', 'pr.closed',
36
+ ]));
37
+
38
+ const DEFAULT_CAP = 8;
39
+ const CONSUMER_CURSOR_FILE = 'consumer.cursor';
40
+
41
+ /** Absolute `.forge/pr-monitor` root for a project. */
42
+ function monitorRoot(root) {
43
+ return path.join(root, '.forge', 'pr-monitor');
44
+ }
45
+
46
+ /** The per-PR consumer cursor path (distinct from the watcher's snapshot/pid). */
47
+ function cursorPath(dir) {
48
+ return path.join(dir, CONSUMER_CURSOR_FILE);
49
+ }
50
+
51
+ /**
52
+ * List absolute PR journal dirs (those containing an events.ndjson). Fail-open:
53
+ * a missing monitor root or unreadable dir yields []. Injectable fs for tests.
54
+ *
55
+ * @param {string} root
56
+ * @param {{ readdirSync?: Function, existsSync?: Function }} [deps]
57
+ * @returns {string[]}
58
+ */
59
+ function discoverPrDirs(root, deps = {}) {
60
+ const readdir = deps.readdirSync || fs.readdirSync;
61
+ const exists = deps.existsSync || fs.existsSync;
62
+ try {
63
+ return readdir(monitorRoot(root), { withFileTypes: true })
64
+ .filter((e) => e.isDirectory())
65
+ .map((e) => path.join(monitorRoot(root), e.name))
66
+ .filter((dir) => exists(path.join(dir, 'events.ndjson')));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Read a PR's consumer cursor (the last consumed seq). Fail-open → 0 (no cursor,
74
+ * unreadable, or malformed all mean "start from the beginning").
75
+ *
76
+ * @param {string} dir
77
+ * @param {{ readFileSync?: Function }} [deps]
78
+ * @returns {number}
79
+ */
80
+ function readConsumerCursor(dir, deps = {}) {
81
+ const readFile = deps.readFileSync || fs.readFileSync;
82
+ try {
83
+ const obj = JSON.parse(readFile(cursorPath(dir), 'utf8'));
84
+ const seq = Number(obj && obj.seq);
85
+ return Number.isFinite(seq) && seq >= 0 ? seq : 0;
86
+ } catch {
87
+ return 0;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Persist a PR's consumer cursor. Fail-open: an unwritable cursor returns false
93
+ * (next turn re-reads the same events — a duplicate nudge, never a crash).
94
+ *
95
+ * @param {string} dir
96
+ * @param {number} seq
97
+ * @param {{ writeFileSync?: Function }} [deps]
98
+ * @returns {boolean}
99
+ */
100
+ function writeConsumerCursor(dir, seq, deps = {}) {
101
+ const writeFile = deps.writeFileSync || fs.writeFileSync;
102
+ try {
103
+ writeFile(cursorPath(dir), `${JSON.stringify({ seq: Number(seq) || 0 })}\n`);
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A compact one-line label for a budget event. PURE. Bounded so injected context
112
+ * stays tiny; the full record is always available via `forge shepherd events`.
113
+ *
114
+ * @param {object} e - a journal event.
115
+ * @returns {string}
116
+ */
117
+ function renderEventLine(e) {
118
+ const pr = e.pr != null ? `#${e.pr}` : '#?';
119
+ const d = e.data || {};
120
+ let detail;
121
+ switch (e.type) {
122
+ case 'verdict.changed': detail = (e.verdict && (e.verdict.verdict || e.verdict.state)) || d.verdict || d.to || ''; break;
123
+ case 'check.failed': detail = d.name || d.check || ''; break;
124
+ case 'thread.opened': detail = d.author ? `by ${d.author}` : (d.path || ''); break;
125
+ case 'pr.merged': detail = 'merged'; break;
126
+ case 'pr.closed': detail = 'closed'; break;
127
+ default: detail = '';
128
+ }
129
+ const suffix = detail ? `: ${String(detail).slice(0, 60)}` : '';
130
+ return `- PR ${pr} ${e.type}${suffix}`;
131
+ }
132
+
133
+ /**
134
+ * PURE: filter events to the budget types, cap the count, and render lines.
135
+ *
136
+ * @param {object[]} events
137
+ * @param {{ cap?: number }} [opts]
138
+ * @returns {{ lines: string[], total: number }}
139
+ */
140
+ function renderDigestLines(events, { cap = DEFAULT_CAP } = {}) {
141
+ const budget = (Array.isArray(events) ? events : []).filter((e) => e && BUDGET_TYPES.has(e.type));
142
+ const lines = budget.map(renderEventLine);
143
+ return { lines: lines.slice(0, cap), total: lines.length };
144
+ }
145
+
146
+ /**
147
+ * Format the compact injected block (a header + capped lines + an overflow
148
+ * pointer), or '' when there is nothing to surface. PURE.
149
+ */
150
+ function formatBlock(lines, total, cap, prs) {
151
+ if (lines.length === 0) return '';
152
+ const on = prs.length ? ` on PR(s) ${prs.join(', ')}` : '';
153
+ const more = total > cap ? `\n(+${total - cap} more — see \`forge shepherd events <pr> --since <seq>\`)` : '';
154
+ return `[forge PR shepherd] ${total} new event(s)${on}:\n${lines.join('\n')}${more}`;
155
+ }
156
+
157
+ /**
158
+ * Collect a compact digest of NEW budget events across every PR journal since the
159
+ * per-PR consumer cursor, advancing each cursor past EVERYTHING read (budget and
160
+ * non-budget alike, so skipped types never re-surface). Fail-open throughout.
161
+ *
162
+ * @param {object} args
163
+ * @param {string} args.root - project root.
164
+ * @param {object} [args.journal] - journal module (test injection).
165
+ * @param {number} [args.cap] - max lines in the block.
166
+ * @param {object} [args.fsDeps] - injectable fs for discovery + cursor I/O.
167
+ * @returns {{ text: string, total: number, prs: string[] }}
168
+ */
169
+ function collectDigest({ root, journal = journalMod, cap = DEFAULT_CAP, fsDeps = {} } = {}) {
170
+ const dirs = discoverPrDirs(root, fsDeps);
171
+ const allLines = [];
172
+ const prs = new Set();
173
+ for (const dir of dirs) {
174
+ const cursor = readConsumerCursor(dir, fsDeps);
175
+ let evs;
176
+ try {
177
+ evs = journal.readEventsSince(dir, cursor);
178
+ } catch {
179
+ evs = [];
180
+ }
181
+ if (!Array.isArray(evs) || evs.length === 0) continue;
182
+ const maxSeq = evs.reduce((m, e) => Math.max(m, Number(e.seq) || 0), cursor);
183
+ for (const e of evs) {
184
+ if (!e || !BUDGET_TYPES.has(e.type)) continue;
185
+ allLines.push(renderEventLine(e));
186
+ if (e.pr != null) prs.add(String(e.pr));
187
+ }
188
+ writeConsumerCursor(dir, maxSeq, fsDeps);
189
+ }
190
+ const capped = allLines.slice(0, cap);
191
+ return { text: formatBlock(capped, allLines.length, cap, [...prs]), total: allLines.length, prs: [...prs] };
192
+ }
193
+
194
+ module.exports = {
195
+ BUDGET_TYPES,
196
+ DEFAULT_CAP,
197
+ monitorRoot,
198
+ cursorPath,
199
+ discoverPrDirs,
200
+ readConsumerCursor,
201
+ writeConsumerCursor,
202
+ renderEventLine,
203
+ renderDigestLines,
204
+ formatBlock,
205
+ collectDigest,
206
+ };
@@ -5,12 +5,13 @@
5
5
  * result (lib/pr-bundle.js) into the Markdown body of the single sticky PR
6
6
  * comment the pr-monitor GitHub workflow keeps up to date.
7
7
  *
8
- * This is the SURFACE half of the monitor: it lists the unresolved review
9
- * threads (grouped by author, ANY author) plus the failing and pending checks
10
- * so async review-bot / human feedback in a window nobody is watching cannot
11
- * rot. It is deliberately NOT a merge decision it never emits a pass/fail
12
- * verdict and never claims a PR is ready to merge. That belongs to the
13
- * trustworthy-shepherd redesign, not here.
8
+ * This is the SURFACE half of the monitor: it leads with the one-line actionable
9
+ * verdict (mirroring the `pr-verdict:*` label the workflow lands), then lists the
10
+ * unresolved review threads (grouped by author, ANY author) plus the failing and
11
+ * pending checks so async review-bot / human feedback in a window nobody is
12
+ * watching cannot rot. The verdict LABELS state (check-failed / threads-open /
13
+ * mergeable / …); it is NOT a merge action — it never merges, never resolves
14
+ * threads, never blocks, and is fail-closed (`unknown` on unreadable signals).
14
15
  *
15
16
  * Pure and deterministic: same bundle + same injected clock → same body, which
16
17
  * is what lets the workflow rewrite the sticky comment in place without churn.
@@ -18,6 +19,33 @@
18
19
  * @module pr-monitor/render-sticky
19
20
  */
20
21
 
22
+ /**
23
+ * Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
24
+ * The verdict VALUE is computed once by pr-pull (`forge shepherd --pull --json`)
25
+ * and passed in — this map only decides how to DISPLAY it, so there is no second
26
+ * verdict ladder to drift.
27
+ */
28
+ const VERDICT_HEADLINE = {
29
+ UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
30
+ 'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
31
+ BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
32
+ 'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
33
+ 'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
34
+ 'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
35
+ 'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
36
+ };
37
+
38
+ /**
39
+ * Render the one-line verdict headline for a canonical verdict string. Unknown or
40
+ * missing input falls closed to the `unknown` headline.
41
+ *
42
+ * @param {string} verdict
43
+ * @returns {string}
44
+ */
45
+ function verdictHeadline(verdict) {
46
+ return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
47
+ }
48
+
21
49
  /** Hidden HTML marker: the workflow finds its prior comment by this string and
22
50
  * UPDATES it in place, so the monitor never spams a PR with new comments. */
23
51
  const STICKY_MARKER = '<!-- forge-pr-monitor -->';
@@ -130,7 +158,13 @@ function renderStickyComment(bundle = {}, opts = {}) {
130
158
  lines.push(STICKY_MARKER);
131
159
  lines.push('## 🔭 Forge PR Monitor');
132
160
  lines.push('');
133
- lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and does not post a pass/fail verdict._');
161
+ // Lead with the actionable verdict the SAME value as the pr-verdict:* label
162
+ // and `forge shepherd --pull --json` (passed in via opts.verdict, computed once
163
+ // by pr-pull). Surface only: it labels state; this monitor **does not merge**
164
+ // and never resolves review threads.
165
+ lines.push(verdictHeadline(opts.verdict));
166
+ lines.push('');
167
+ lines.push('_Surfaces open review + check state so async feedback never rots. This monitor **does not merge** and never resolves review threads — a human merges in the GitHub UI._');
134
168
  lines.push('');
135
169
 
136
170
  renderThreads(bundle, lines);
@@ -143,13 +177,14 @@ function renderStickyComment(bundle = {}, opts = {}) {
143
177
  }
144
178
 
145
179
  lines.push('---');
146
- lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · never merges, never verdicts.</sub>`);
180
+ lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · labels state, never merges, never resolves threads.</sub>`);
147
181
 
148
182
  return { marker: STICKY_MARKER, body: lines.join('\n') };
149
183
  }
150
184
 
151
185
  module.exports = {
152
186
  renderStickyComment,
187
+ verdictHeadline,
153
188
  groupByAuthor,
154
189
  threadLocator,
155
190
  STICKY_MARKER,
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor sticky-comment upsert — race-safe, converges to EXACTLY ONE sticky
5
+ * comment per PR even under a concurrent burst of workflow runs.
6
+ *
7
+ * Why this exists: the pr-monitor workflow deliberately has NO `concurrency:`
8
+ * group. A per-PR group does not help — GitHub's queue replacement cancels the
9
+ * previously-PENDING run in a group UNCONDITIONALLY (independent of
10
+ * cancel-in-progress), and this workflow's triggers (check_suite:completed fires
11
+ * ~10+ times per push, plus reviews/comments) burst hard, so a group left a trail
12
+ * of CANCELLED runs that render as red/non-SUCCESS checks and tripped merge-gate
13
+ * tooling (kernel issue 97e6a146). Dropping the group removes the cancellations,
14
+ * but then two concurrent first-runs on a PR with no sticky yet would BOTH find
15
+ * nothing and BOTH create one → duplicate sticky comments. This module closes
16
+ * that race deterministically instead.
17
+ *
18
+ * Reconcile-to-one algorithm:
19
+ * 1. List marker comments. If none, create one, then RE-LIST (a concurrent run
20
+ * may have created its own in the same burst).
21
+ * 2. Pick the deterministic survivor: the LOWEST comment id (oldest). Every
22
+ * concurrent run picks the SAME survivor, so they never fight.
23
+ * 3. Update the survivor with the latest body; delete every other marker
24
+ * comment. Deletes are idempotent (a 404 means a peer already removed it).
25
+ *
26
+ * A create that lands AFTER a run's re-list is self-healed by the next event:
27
+ * every run reconciles to one, and events keep arriving, so the PR converges to a
28
+ * single sticky comment.
29
+ *
30
+ * @module pr-monitor/upsert-sticky
31
+ */
32
+
33
+ const { execFileSync } = require('node:child_process');
34
+
35
+ /** Ids of comments whose body carries the sticky marker. */
36
+ function markerCommentIds(comments, marker) {
37
+ return (Array.isArray(comments) ? comments : [])
38
+ .filter((comment) => typeof comment.body === 'string' && comment.body.includes(marker))
39
+ .map((comment) => comment.id);
40
+ }
41
+
42
+ /** Ascending by numeric id, so the survivor (index 0) is the oldest comment. */
43
+ function sortIdsAscending(ids) {
44
+ return [...ids].sort((left, right) => Number(left) - Number(right));
45
+ }
46
+
47
+ /**
48
+ * Drive a client to exactly one sticky comment. `client` abstracts the GitHub
49
+ * calls so the reconcile logic is unit-testable without the network:
50
+ * - list() → array of { id, body }
51
+ * - create() → create a new sticky comment (body supplied by the client)
52
+ * - update(id) → overwrite comment `id` with the latest body
53
+ * - remove(id) → delete comment `id` (must tolerate an already-deleted 404)
54
+ *
55
+ * @returns {Promise<{ survivor: (number|string|null), deleted: Array<number|string> }>}
56
+ */
57
+ async function upsertStickyComment({ marker }, client) {
58
+ let ids = markerCommentIds(await client.list(), marker);
59
+
60
+ if (ids.length === 0) {
61
+ await client.create();
62
+ // Re-list: a concurrent run may have created its own sticky in this burst.
63
+ ids = markerCommentIds(await client.list(), marker);
64
+ }
65
+
66
+ if (ids.length === 0) {
67
+ // The just-created comment is not visible yet (eventual consistency); its
68
+ // body is already correct, and the next event will reconcile if a peer raced.
69
+ return { survivor: null, deleted: [] };
70
+ }
71
+
72
+ ids = sortIdsAscending(ids);
73
+ const survivor = ids[0];
74
+ await client.update(survivor);
75
+
76
+ const deleted = [];
77
+ for (const id of ids.slice(1)) {
78
+ await client.remove(id);
79
+ deleted.push(id);
80
+ }
81
+
82
+ return { survivor, deleted };
83
+ }
84
+
85
+ // Single choke point for the `gh` CLI, matching lib/commands/merge.js. `gh` is a
86
+ // hardcoded literal (never user input) and args are an array (no shell), so the
87
+ // S4036 PATH-search finding is a false positive in this developer-tool context;
88
+ // one annotation here covers every call site. `encoding: 'utf8'` also pipes
89
+ // stderr onto the thrown error, so isAlreadyGone() below can classify failures.
90
+ function runGh(args) {
91
+ return execFileSync('gh', args, { encoding: 'utf8' }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context
92
+ }
93
+
94
+ /**
95
+ * True only when a `gh api` failure means the target comment is already absent
96
+ * (HTTP 404 / 410) — the benign "a peer got there first" case. Auth failures,
97
+ * rate limits (403), and every other error return false so they propagate and
98
+ * surface a diagnostic instead of silently breaking the exactly-one invariant.
99
+ */
100
+ function isAlreadyGone(error) {
101
+ const text = `${error && error.stderr ? error.stderr : ''} ${error && error.message ? error.message : ''}`;
102
+ return /HTTP 404|HTTP 410|\bNot Found\b|\bGone\b/i.test(text);
103
+ }
104
+
105
+ /**
106
+ * GitHub-backed client (shells to `gh api`, like lib/pr-monitor/gather.js). The
107
+ * reconcile logic above is unit-tested; the error-tolerance in update/remove is
108
+ * too, via an injectable `run`. create/update both send the same pre-rendered
109
+ * payload file the render step wrote, so the body is identical.
110
+ */
111
+ function ghStickyClient({ repo, pr, payloadFile, run = runGh }) {
112
+ return {
113
+ async list() {
114
+ const out = run(['api', `repos/${repo}/issues/${pr}/comments`, '--paginate']);
115
+ return out && out.trim() ? JSON.parse(out) : [];
116
+ },
117
+ async create() {
118
+ run(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '--input', payloadFile]);
119
+ },
120
+ async update(id) {
121
+ try {
122
+ run(['api', '-X', 'PATCH', `repos/${repo}/issues/comments/${id}`, '--input', payloadFile]);
123
+ } catch (error) {
124
+ if (!isAlreadyGone(error)) {
125
+ throw error;
126
+ }
127
+ // Our chosen survivor was deleted by a peer whose survivor had a lower id:
128
+ // the peer's sticky wins, exactly-one still holds, so we are done.
129
+ }
130
+ },
131
+ async remove(id) {
132
+ try {
133
+ run(['api', '-X', 'DELETE', `repos/${repo}/issues/comments/${id}`]);
134
+ } catch (error) {
135
+ if (!isAlreadyGone(error)) {
136
+ throw error;
137
+ }
138
+ // Already gone (a concurrent run deleted it first) — the goal state holds.
139
+ }
140
+ },
141
+ };
142
+ }
143
+
144
+ async function main() {
145
+ const repo = process.env.GH_REPO;
146
+ const pr = process.env.PR;
147
+ const payloadFile = process.env.STICKY_PAYLOAD_FILE || 'monitor-payload.json';
148
+ const { STICKY_MARKER } = require('./render-sticky');
149
+
150
+ const client = ghStickyClient({ repo, pr, payloadFile });
151
+ const { survivor, deleted } = await upsertStickyComment({ marker: STICKY_MARKER }, client);
152
+ const survivorLabel = survivor === null ? 'created (not yet visible)' : survivor;
153
+ console.log(`Sticky comment reconciled to one: survivor=${survivorLabel}, deleted=${deleted.length}`);
154
+ }
155
+
156
+ if (require.main === module) {
157
+ main().catch((error) => {
158
+ console.error(error.message);
159
+ process.exit(1);
160
+ });
161
+ }
162
+
163
+ module.exports = {
164
+ upsertStickyComment,
165
+ markerCommentIds,
166
+ sortIdsAscending,
167
+ isAlreadyGone,
168
+ ghStickyClient,
169
+ };