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

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 (155) 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 +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The debounce / cost guard for the autonomous shepherd (W-S4 design §2).
5
+ *
6
+ * `tick()` is the cheap-path-first gate that fires from an arbitrary `forge`
7
+ * command (W-S4b wires the call site). Its whole job is to make the HOT path — a
8
+ * live daemon already converging — cost ~one `readFileSync` and return, so a
9
+ * per-command trigger never regresses into a `gh` call per command.
10
+ *
11
+ * Three gates:
12
+ * G1 A fresh lease (heartbeat < STALE_MS) means a live daemon is converging —
13
+ * do NOTHING (no stat, no enumerate, no spawn).
14
+ * G2 The enumeration window (RECONCILE_MIN_INTERVAL) has not elapsed since the
15
+ * last cold tick — skip enumeration.
16
+ * G3 Cold tick: bump the sentinel mtime FIRST (throttle even if gh is slow),
17
+ * then run the INJECTED enumerate()+reconcile()+execute().
18
+ *
19
+ * The sentinel file `<gitCommonDir>/forge/shepherd.reconcile` is the throttle
20
+ * stamp — its **mtime IS `last_enumerated_at`** (no content, no parse). It is a
21
+ * SEPARATE file from the token-guarded lease payload precisely because a tick
22
+ * fired by an arbitrary command does NOT hold the lease token and so cannot write
23
+ * the lease (design correction #3) — but any process may bump this sentinel.
24
+ *
25
+ * This module does NOT spawn — `enumerate`/`execute`/`reconcile` are injected, so
26
+ * the guard is fully testable with a fake clock + temp dir and 0 real I/O beyond
27
+ * the lock read and sentinel stat/bump. The daemon executor + spawn wiring are
28
+ * W-S4b.
29
+ *
30
+ * @module pr-monitor/reconcile-tick
31
+ */
32
+
33
+ const fs = require('node:fs');
34
+ const path = require('node:path');
35
+ const { STALE_MS } = require('./shepherd-lease');
36
+ const { reconcile: defaultReconcile } = require('./reconcile');
37
+
38
+ /** Default minimum interval between cold enumerations, per repo (ms). */
39
+ const RECONCILE_MIN_INTERVAL = 60000;
40
+
41
+ /** Resolve the enumeration window, honoring the env override at call time (tests). */
42
+ function resolveMinInterval() {
43
+ const override = Number(process.env.FORGE_RECONCILE_MIN_INTERVAL);
44
+ return Number.isFinite(override) && override > 0 ? override : RECONCILE_MIN_INTERVAL;
45
+ }
46
+
47
+ function forgeDir(gitCommonDir) {
48
+ return path.join(gitCommonDir, 'forge');
49
+ }
50
+ function lockPath(gitCommonDir) {
51
+ return path.join(forgeDir(gitCommonDir), 'shepherd.lock');
52
+ }
53
+ function sentinelPath(gitCommonDir) {
54
+ return path.join(forgeDir(gitCommonDir), 'shepherd.reconcile');
55
+ }
56
+
57
+ /** Read + parse the lock payload, or null when missing/unreadable/corrupt. */
58
+ function readLock(gitCommonDir) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(lockPath(gitCommonDir), 'utf8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Stat the sentinel, or null when it does not exist yet. */
67
+ function statSentinel(gitCommonDir) {
68
+ try {
69
+ return fs.statSync(sentinelPath(gitCommonDir));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Bump the sentinel mtime to `t` (create it if absent). mtime IS last_enumerated_at. */
76
+ function bumpSentinel(gitCommonDir, t) {
77
+ const file = sentinelPath(gitCommonDir);
78
+ fs.mkdirSync(path.dirname(file), { recursive: true });
79
+ if (!fs.existsSync(file)) fs.writeFileSync(file, '', { mode: 0o600 });
80
+ const secs = t / 1000;
81
+ fs.utimesSync(file, secs, secs);
82
+ }
83
+
84
+ /**
85
+ * Run one debounce tick. Returns `{ path: 'G1'|'G2'|'G3', actions? }` describing
86
+ * which gate fired (the `actions` are the reconcile output on the G3 cold path).
87
+ *
88
+ * Injected seams (all keep the guard hermetic — no gh/spawn/clock of its own):
89
+ * now () => ms — clock (default Date.now)
90
+ * enumerate () => {desired, observed} — the expensive gh∩kernel gather (G3 only)
91
+ * execute (actions) => void — the action dispatcher (G3 only)
92
+ * reconcile (desired, observed, now) => {actions} — pure core (default: the real one)
93
+ * minInterval ms — enumeration window (default: env-or-60000)
94
+ */
95
+ function tick({
96
+ gitCommonDir,
97
+ now = () => Date.now(),
98
+ enumerate,
99
+ execute,
100
+ reconcile = defaultReconcile,
101
+ minInterval = resolveMinInterval(),
102
+ } = {}) {
103
+ const t = now();
104
+
105
+ // G1 — a fresh lease means a live daemon is converging. Do NOTHING (hot path:
106
+ // one readFileSync + JSON.parse, then return; no stat, no enumerate, no spawn).
107
+ const lock = readLock(gitCommonDir);
108
+ if (lock) {
109
+ const beat = Date.parse(lock.heartbeatAt);
110
+ if (Number.isFinite(beat) && (t - beat) < STALE_MS) {
111
+ return { path: 'G1' };
112
+ }
113
+ }
114
+
115
+ // G2 — the enumeration window has not elapsed since the last cold tick. Trust the
116
+ // last enumeration; skip the expensive gather. (Daemon revive on this path is W-S4b.)
117
+ const sentinelStat = statSentinel(gitCommonDir);
118
+ if (sentinelStat && (t - sentinelStat.mtimeMs) < minInterval) {
119
+ return { path: 'G2' };
120
+ }
121
+
122
+ // G3 — cold tick: no fresh lease AND the window elapsed. Bump the sentinel mtime
123
+ // FIRST so the throttle holds even if the enumerate() gh call is slow, THEN run the
124
+ // injected enumerate → reconcile → execute.
125
+ bumpSentinel(gitCommonDir, t);
126
+ const { desired, observed } = enumerate();
127
+ const { actions } = reconcile(desired, observed, t);
128
+ execute(actions);
129
+ return { path: 'G3', actions };
130
+ }
131
+
132
+ module.exports = {
133
+ tick,
134
+ RECONCILE_MIN_INTERVAL,
135
+ resolveMinInterval,
136
+ lockPath,
137
+ sentinelPath,
138
+ };
Binary file
@@ -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 -->';
@@ -119,6 +147,10 @@ function renderChecks(bundle, lines) {
119
147
  * @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js).
120
148
  * @param {object} [opts]
121
149
  * @param {Date} [opts.now] - injected clock for deterministic output.
150
+ * @param {string} [opts.verdict] - the canonical `--pull` verdict.
151
+ * @param {string[]} [opts.unreadable] - unreadable signal names (from the `--pull`
152
+ * evidence). Surfaced when the verdict is UNKNOWN so the sticky always says WHICH
153
+ * signal could not be read, not just a bare `unknown`.
122
154
  * @returns {{ marker: string, body: string }}
123
155
  */
124
156
  function renderStickyComment(bundle = {}, opts = {}) {
@@ -130,7 +162,23 @@ function renderStickyComment(bundle = {}, opts = {}) {
130
162
  lines.push(STICKY_MARKER);
131
163
  lines.push('## 🔭 Forge PR Monitor');
132
164
  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._');
165
+ // Lead with the actionable verdict the SAME value as the pr-verdict:* label
166
+ // and `forge shepherd --pull --json` (passed in via opts.verdict, computed once
167
+ // by pr-pull). Surface only: it labels state; this monitor **does not merge**
168
+ // and never resolves review threads.
169
+ lines.push(verdictHeadline(opts.verdict));
170
+ // An `unknown` verdict is useless without the WHY — name the unreadable
171
+ // signal(s) so a human/agent knows what to fix (e.g. `requiredChecks` when both
172
+ // branch-protection AND the rollup fallback could not be read).
173
+ const verdictUpper = String(opts.verdict || '').toUpperCase();
174
+ const isUnknown = verdictUpper === 'UNKNOWN' || !VERDICT_HEADLINE[verdictUpper];
175
+ const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
176
+ if (isUnknown && unreadable.length > 0) {
177
+ lines.push('');
178
+ lines.push(`> Unreadable signal(s): ${unreadable.map((s) => `\`${s}\``).join(', ')}.`);
179
+ }
180
+ lines.push('');
181
+ 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
182
  lines.push('');
135
183
 
136
184
  renderThreads(bundle, lines);
@@ -143,13 +191,14 @@ function renderStickyComment(bundle = {}, opts = {}) {
143
191
  }
144
192
 
145
193
  lines.push('---');
146
- lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · never merges, never verdicts.</sub>`);
194
+ lines.push(`<sub>Updated ${now.toISOString()} · surface-only monitor · labels state, never merges, never resolves threads.</sub>`);
147
195
 
148
196
  return { marker: STICKY_MARKER, body: lines.join('\n') };
149
197
  }
150
198
 
151
199
  module.exports = {
152
200
  renderStickyComment,
201
+ verdictHeadline,
153
202
  groupByAuthor,
154
203
  threadLocator,
155
204
  STICKY_MARKER,
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shepherd singleton lease — a machine-wide "one watcher-set" guard for the PR
5
+ * shepherd, fusing two existing precedents:
6
+ *
7
+ * - `serve.lock`'s exclusive-create + foreign-PID block + stale reclaim
8
+ * (`lib/commands/_serve-security.js`), so a live foreign owner is never
9
+ * stolen but a dead/wedged one is reclaimed.
10
+ * - the journal lock's heartbeat + TTL staleness
11
+ * (`lib/pr-monitor/journal.js`), so a slow-but-alive owner refreshes its
12
+ * timestamp and a crashed owner ages out.
13
+ *
14
+ * The lock lives at `<gitCommonDir>/forge/shepherd.lock`, keyed by the SAME
15
+ * `resolveGitCommonDir` the kernel DB uses — so every worktree of a repo shares
16
+ * one lock. This module is the lease PRIMITIVE only: pure fs + I/O, no spawned
17
+ * process and no reconcile loop (the daemon wires those up later).
18
+ *
19
+ * Payload JSON: `{ pid, token, startedAt, heartbeatAt, watchers: [prNumbers] }`.
20
+ *
21
+ * ## Ownership is by TOKEN, not pid
22
+ * Each successful `acquire` mints a unique `token`. Every mutating op
23
+ * (`stamp`/`updateWatchers`/`release`) verifies that token against the on-disk
24
+ * lock before writing. A pid can be reused after a crash/reboot, and a wedged
25
+ * owner can revive after its lease was reclaimed — in both cases the token no
26
+ * longer matches, so the superseded holder can never resurrect or mutate a lease
27
+ * it no longer owns. Takeover of a stale lock is made atomic by an O_EXCL create
28
+ * (only one racer can win it), so two processes reclaiming the same stale lock
29
+ * can never both succeed.
30
+ *
31
+ * @module pr-monitor/shepherd-lease
32
+ */
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const crypto = require('node:crypto');
37
+ const { resolveGitCommonDir } = require('../kernel/broker');
38
+
39
+ /** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
40
+ const STALE_MS = 30000;
41
+ const LOCK_FILE_MODE = 0o600;
42
+
43
+ /**
44
+ * Resolve the shared lock path. `gitCommonDir` may be injected (tests, or a
45
+ * caller that already resolved it); otherwise it is resolved from `projectRoot`
46
+ * with the same resolver the kernel broker uses.
47
+ */
48
+ function lockFilePath(projectRoot, opts = {}) {
49
+ const gitCommonDir = opts.gitCommonDir
50
+ ? path.resolve(opts.gitCommonDir)
51
+ : resolveGitCommonDir(projectRoot, opts);
52
+ return path.join(gitCommonDir, 'forge', 'shepherd.lock');
53
+ }
54
+
55
+ /** Parse the lock payload, or null when missing/unreadable/corrupt. */
56
+ function readLock(file) {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ // Is `pid` a live process? `process.kill(pid, 0)` sends no signal but throws
65
+ // ESRCH when the pid is gone. EPERM means it exists but isn't ours — still live.
66
+ function pidAlive(pid) {
67
+ if (!Number.isInteger(pid) || pid <= 0) return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (err) {
72
+ return err.code === 'EPERM';
73
+ }
74
+ }
75
+
76
+ /** Is a held lock stale (dead owner, or heartbeat older than STALE_MS)? */
77
+ function isHeldStale(held, { isAlive, now }) {
78
+ if (!isAlive(held.pid)) return true;
79
+ const beat = Date.parse(held.heartbeatAt);
80
+ if (!Number.isFinite(beat)) return true;
81
+ return now() - beat >= STALE_MS;
82
+ }
83
+
84
+ /**
85
+ * A lock is "ours" iff its unique lease `token` matches the one `acquire`
86
+ * returned. Token is authoritative: a pid can be reused after a crash/reboot and
87
+ * a wedged owner can revive after being reclaimed, so a pid match alone is NOT
88
+ * proof of ownership. Only a hypothetical legacy lock with no `token` field falls
89
+ * back to pid comparison.
90
+ */
91
+ function ownsLock(held, { token, pid }) {
92
+ if (held.token) return token !== undefined && held.token === token;
93
+ return held.pid === pid;
94
+ }
95
+
96
+ function writeLock(file, payload) {
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ fs.writeFileSync(file, JSON.stringify(payload), { mode: LOCK_FILE_MODE });
99
+ }
100
+
101
+ /**
102
+ * Atomically create the lock with O_EXCL and write `payload`. Returns true when
103
+ * we won the create, false on EEXIST (a concurrent contender holds it); other
104
+ * errors rethrow. The O_EXCL create is the single atomic arbiter — of N racers
105
+ * attempting it against the same absent path, exactly one succeeds.
106
+ */
107
+ function tryExclusiveCreate(file, payload) {
108
+ let fd;
109
+ try {
110
+ fd = fs.openSync(file, 'wx', LOCK_FILE_MODE);
111
+ } catch (err) {
112
+ if (err.code === 'EEXIST') return false;
113
+ throw err;
114
+ }
115
+ try {
116
+ fs.writeSync(fd, JSON.stringify(payload));
117
+ } finally {
118
+ fs.closeSync(fd);
119
+ }
120
+ return true;
121
+ }
122
+
123
+ /**
124
+ * Try to claim the singleton lease.
125
+ * -> { ok:true, file, token } first claim
126
+ * -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
127
+ * -> { ok:false, held } a LIVE, FRESH foreign owner holds it
128
+ * -> { ok:false, held } we lost the atomic takeover race
129
+ *
130
+ * `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
131
+ * a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
132
+ * re-create, so a test can simulate a competitor winning the O_EXCL create first
133
+ * (making our takeover lose).
134
+ */
135
+ function acquire(projectRoot, {
136
+ gitCommonDir,
137
+ pid = process.pid,
138
+ isAlive = pidAlive,
139
+ now = () => Date.now(),
140
+ token = crypto.randomUUID(),
141
+ onBeforeTakeover = null,
142
+ } = {}) {
143
+ const file = lockFilePath(projectRoot, { gitCommonDir });
144
+ fs.mkdirSync(path.dirname(file), { recursive: true });
145
+ const iso = new Date(now()).toISOString();
146
+ const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
147
+
148
+ // Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
149
+ if (tryExclusiveCreate(file, payload)) {
150
+ return { ok: true, file, token };
151
+ }
152
+
153
+ // A lock exists. A DIFFERENT, live, fresh owner blocks us.
154
+ const held = readLock(file);
155
+ if (held && held.pid !== pid && !isHeldStale(held, { isAlive, now })) {
156
+ return { ok: false, held };
157
+ }
158
+
159
+ // Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
160
+ // remove the stale lock, then re-create it with O_EXCL. If a competitor
161
+ // recreated it first, our exclusive create fails (EEXIST) and we back off — so
162
+ // two racers reclaiming the same stale lock can NEVER both win. A wedged owner
163
+ // that revives after we delete its lock is stopped by the per-lease `token`
164
+ // guard on stamp()/updateWatchers()/release(), never able to resurrect it here.
165
+ fs.rmSync(file, { force: true });
166
+ if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
167
+ if (!tryExclusiveCreate(file, payload)) {
168
+ return { ok: false, held: readLock(file) };
169
+ }
170
+ return { ok: true, file, token, reclaimed: true };
171
+ }
172
+
173
+ /**
174
+ * Refresh `heartbeatAt` on OUR lock. Returns false (a no-op) when the lock is
175
+ * missing or NOT ours by `token` — we never stamp a lease we no longer own, so a
176
+ * revived wedged owner cannot resurrect a reclaimed lease.
177
+ */
178
+ function stamp(projectRoot, { gitCommonDir, token, pid = process.pid, now = () => Date.now() } = {}) {
179
+ const file = lockFilePath(projectRoot, { gitCommonDir });
180
+ const held = readLock(file);
181
+ if (!held || !ownsLock(held, { token, pid })) return false;
182
+ held.heartbeatAt = new Date(now()).toISOString();
183
+ writeLock(file, held);
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Start a heartbeat timer that stamps `heartbeatAt` every STALE_MS/3. The timer
189
+ * is `.unref()`ed so it never keeps the process alive. `opts` MUST carry the
190
+ * `token` returned by `acquire` (threaded straight through to `stamp`). Returns
191
+ * the handle for `stopHeartbeat`.
192
+ */
193
+ function startHeartbeat(projectRoot, opts = {}) {
194
+ const timer = setInterval(() => stamp(projectRoot, opts), Math.max(1, Math.floor(STALE_MS / 3)));
195
+ if (typeof timer.unref === 'function') timer.unref();
196
+ return timer;
197
+ }
198
+
199
+ /** Stop a heartbeat timer started by `startHeartbeat`. */
200
+ function stopHeartbeat(timer) {
201
+ if (timer) clearInterval(timer);
202
+ }
203
+
204
+ /**
205
+ * Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
206
+ * missing or NOT ours by `token`.
207
+ */
208
+ function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
209
+ const file = lockFilePath(projectRoot, { gitCommonDir });
210
+ const held = readLock(file);
211
+ if (!held || !ownsLock(held, { token, pid })) return false;
212
+ held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
213
+ writeLock(file, held);
214
+ return true;
215
+ }
216
+
217
+ /**
218
+ * Release the lease — delete the lock ONLY when it is ours by `token`, so a
219
+ * foreign or already-reclaimed lock is never removed out from under its owner
220
+ * (and a reused pid can never delete someone else's lease).
221
+ */
222
+ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
223
+ const file = lockFilePath(projectRoot, { gitCommonDir });
224
+ try {
225
+ const held = readLock(file);
226
+ if (held && ownsLock(held, { token, pid })) fs.rmSync(file, { force: true });
227
+ } catch {
228
+ /* best effort — a failed release just leaves a stale lock to be reclaimed */
229
+ }
230
+ }
231
+
232
+ module.exports = {
233
+ STALE_MS,
234
+ lockFilePath,
235
+ pidAlive,
236
+ ownsLock,
237
+ acquire,
238
+ stamp,
239
+ startHeartbeat,
240
+ stopHeartbeat,
241
+ updateWatchers,
242
+ release,
243
+ };
@@ -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', windowsHide: true }); // NOSONAR S4036 - hardcoded CLI (gh), args array (no shell), developer-tool context. windowsHide: sticky upsert runs in the detached watcher (issue 931e7924).
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
+ };
@@ -35,7 +35,7 @@ function forgeBin() {
35
35
  function defaultResolveSlug({ cwd, exec = execFileSync }) {
36
36
  try {
37
37
  const url = exec('git', ['remote', 'get-url', 'origin'], {
38
- cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
38
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
39
39
  }).trim();
40
40
  const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
41
41
  return match ? match[1] : null;