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

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 (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PR-monitor Actions-summary renderer. It turns one read-only
5
+ * `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
6
+ * the workflow's `GITHUB_STEP_SUMMARY` surface.
7
+ *
8
+ * This is presentation only: it displays the canonical verdict supplied by
9
+ * `forge shepherd <pr> --pull --json`, lists unresolved review threads and CI
10
+ * state, and never merges or resolves anything. The `pr-verdict:*` label is a
11
+ * cheap visibility projection of that same verdict, not merge authority.
12
+ *
13
+ * @module pr-monitor/render-summary
14
+ */
15
+
16
+ /**
17
+ * Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
18
+ * The verdict is computed once by pr-pull and passed in; this map only decides
19
+ * how it is displayed, so there is no second verdict ladder to drift.
20
+ */
21
+ const VERDICT_HEADLINE = {
22
+ UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
23
+ 'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
24
+ BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
25
+ 'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
26
+ 'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
27
+ 'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
28
+ 'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
29
+ };
30
+
31
+ /** Render the one-line headline for a canonical verdict, failing closed. */
32
+ function verdictHeadline(verdict) {
33
+ return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
34
+ }
35
+
36
+ /**
37
+ * Render untrusted text as a Markdown code span without allowing its backticks
38
+ * or line breaks to change the surrounding summary structure.
39
+ *
40
+ * CommonMark permits a code span to use more than one backtick. Pick a fence
41
+ * longer than every run in the value, and flatten CR/LF so the summary stays
42
+ * one line per diagnostic.
43
+ */
44
+ function mdCode(value) {
45
+ const text = String(value ?? '').replace(/[\r\n]+/g, ' ');
46
+ let longestRun = 0;
47
+ let currentRun = 0;
48
+ for (const character of text) {
49
+ if (character === '`') {
50
+ currentRun += 1;
51
+ longestRun = Math.max(longestRun, currentRun);
52
+ } else {
53
+ currentRun = 0;
54
+ }
55
+ }
56
+ const fence = '`'.repeat(longestRun + 1);
57
+ const content = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
58
+ return `${fence}${content}${fence}`;
59
+ }
60
+
61
+ /** Cap threads listed per author so a noisy PR cannot produce an enormous summary. */
62
+ const MAX_THREADS_PER_AUTHOR = 8;
63
+
64
+ /** Group unresolved review-thread comments by author in deterministic order. */
65
+ function groupByAuthor(comments) {
66
+ const byAuthor = new Map();
67
+ for (const comment of (Array.isArray(comments) ? comments : [])) {
68
+ const author = String(comment.author || 'unknown');
69
+ if (!byAuthor.has(author)) byAuthor.set(author, []);
70
+ byAuthor.get(author).push(comment);
71
+ }
72
+ return [...byAuthor.entries()].sort(
73
+ (a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]),
74
+ );
75
+ }
76
+
77
+ /** One-line locator for a thread: `path:line` when known, else its id. */
78
+ function threadLocator(thread) {
79
+ if (thread.path) return thread.line != null ? `${thread.path}:${thread.line}` : thread.path;
80
+ return thread.threadId || '(thread)';
81
+ }
82
+
83
+ /** Render unresolved review threads, preserving fail-closed availability. */
84
+ function renderThreads(bundle, lines) {
85
+ // Empty arrays are ambiguous when the adapter could not read comments. Only
86
+ // an explicit available:true read may report zero unresolved threads.
87
+ if (bundle.unresolvedCommentsAvailable !== true) {
88
+ const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
89
+ lines.push('### Review threads');
90
+ lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
91
+ lines.push('');
92
+ return;
93
+ }
94
+
95
+ const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
96
+ if (comments.length === 0) {
97
+ lines.push('### Review threads');
98
+ lines.push('✅ No unresolved review threads.');
99
+ lines.push('');
100
+ return;
101
+ }
102
+
103
+ const groups = groupByAuthor(comments);
104
+ lines.push(`### Unresolved review threads (${comments.length})`);
105
+ lines.push('');
106
+ for (const [author, threads] of groups) {
107
+ lines.push(`- **${author}** — ${threads.length}`);
108
+ for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
109
+ lines.push(` - ${mdCode(threadLocator(thread))}`);
110
+ }
111
+ if (threads.length > MAX_THREADS_PER_AUTHOR) {
112
+ lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
113
+ }
114
+ }
115
+ lines.push('');
116
+ }
117
+
118
+ /** Render failing and pending checks, preserving fail-closed availability. */
119
+ function renderChecks(bundle, lines) {
120
+ // Only ciAvailable:true permits a clean-check claim. Missing or false means
121
+ // the read did not complete, so empty arrays must not look green.
122
+ if (bundle.ciAvailable !== true) {
123
+ lines.push('### Checks');
124
+ lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
125
+ lines.push('');
126
+ return;
127
+ }
128
+
129
+ const ci = bundle.ci || {};
130
+ const failing = Array.isArray(ci.failing) ? ci.failing : [];
131
+ const pending = Array.isArray(ci.pending) ? ci.pending : [];
132
+ lines.push('### Checks');
133
+ if (failing.length === 0 && pending.length === 0) {
134
+ lines.push('✅ No failing or pending checks.');
135
+ } else {
136
+ if (failing.length > 0) {
137
+ lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((check) => mdCode(check.name || '?')).join(', ')}`);
138
+ }
139
+ if (pending.length > 0) {
140
+ lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((check) => mdCode(check.name || '?')).join(', ')}`);
141
+ }
142
+ }
143
+ lines.push('');
144
+ }
145
+
146
+ /**
147
+ * Render the PR monitor's Actions job summary.
148
+ *
149
+ * @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js)
150
+ * @param {object} [opts]
151
+ * @param {Date} [opts.now] - injected clock for deterministic output
152
+ * @param {string} [opts.verdict] - canonical `--pull` verdict
153
+ * @param {string[]} [opts.unreadable] - unreadable signal names from `--pull`
154
+ * @param {string|number} [opts.pr] - PR number for the CLI diagnostics hint
155
+ * @returns {{ body: string }}
156
+ */
157
+ function renderSummary(bundle = {}, opts = {}) {
158
+ const now = opts.now instanceof Date ? opts.now : new Date();
159
+ const lines = ['## 🔭 Forge PR Monitor', ''];
160
+ const verdict = String(opts.verdict || '').toUpperCase();
161
+ const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
162
+ const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
163
+
164
+ lines.push(verdictHeadline(opts.verdict));
165
+ if (isUnknown && unreadable.length > 0) {
166
+ lines.push('');
167
+ lines.push(`> Unreadable signal(s): ${unreadable.map((signal) => mdCode(signal)).join(', ')}.`);
168
+ }
169
+ lines.push('');
170
+ 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._');
171
+ lines.push('');
172
+
173
+ renderThreads(bundle, lines);
174
+ renderChecks(bundle, lines);
175
+
176
+ const branch = bundle.branch || {};
177
+ if ((branch.behind || 0) > 0) {
178
+ lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
179
+ lines.push('');
180
+ }
181
+
182
+ const pr = opts.pr || bundle.pr || '<pr>';
183
+ lines.push('---');
184
+ lines.push(`Detailed JSON: ${mdCode(`forge shepherd ${pr} --pull --json`)}`);
185
+ lines.push(`_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`);
186
+
187
+ return { body: lines.join('\n') };
188
+ }
189
+
190
+ module.exports = {
191
+ renderSummary,
192
+ verdictHeadline,
193
+ groupByAuthor,
194
+ threadLocator,
195
+ MAX_THREADS_PER_AUTHOR,
196
+ };
@@ -0,0 +1,252 @@
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
+ // Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
41
+ // enough margin that a blocked event loop cannot be reclaimed mid-read.
42
+ const STALE_MS = 90000;
43
+ const LOCK_FILE_MODE = 0o600;
44
+
45
+ /**
46
+ * Resolve the shared lock path. `gitCommonDir` may be injected (tests, or a
47
+ * caller that already resolved it); otherwise it is resolved from `projectRoot`
48
+ * with the same resolver the kernel broker uses.
49
+ */
50
+ function lockFilePath(projectRoot, opts = {}) {
51
+ const gitCommonDir = opts.gitCommonDir
52
+ ? path.resolve(opts.gitCommonDir)
53
+ : resolveGitCommonDir(projectRoot, opts);
54
+ return path.join(gitCommonDir, 'forge', 'shepherd.lock');
55
+ }
56
+
57
+ /** Parse the lock payload, or null when missing/unreadable/corrupt. */
58
+ function readLock(file) {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // Is `pid` a live process? `process.kill(pid, 0)` sends no signal but throws
67
+ // ESRCH when the pid is gone. EPERM means it exists but isn't ours — still live.
68
+ function pidAlive(pid) {
69
+ if (!Number.isInteger(pid) || pid <= 0) return false;
70
+ try {
71
+ process.kill(pid, 0);
72
+ return true;
73
+ } catch (err) {
74
+ return err.code === 'EPERM';
75
+ }
76
+ }
77
+
78
+ /** Is a held lock stale (dead owner, or heartbeat older than STALE_MS)? */
79
+ function isHeldStale(held, { isAlive, now }) {
80
+ if (!isAlive(held.pid)) return true;
81
+ const beat = Date.parse(held.heartbeatAt);
82
+ if (!Number.isFinite(beat)) return true;
83
+ return now() - beat >= STALE_MS;
84
+ }
85
+
86
+ /**
87
+ * A lock is "ours" iff its unique lease `token` matches the one `acquire`
88
+ * returned. Token is authoritative: a pid can be reused after a crash/reboot and
89
+ * a wedged owner can revive after being reclaimed, so a pid match alone is NOT
90
+ * proof of ownership. Only a hypothetical legacy lock with no `token` field falls
91
+ * back to pid comparison.
92
+ */
93
+ function ownsLock(held, { token, pid }) {
94
+ if (held.token) return token !== undefined && held.token === token;
95
+ return held.pid === pid;
96
+ }
97
+
98
+ function writeLock(file, payload) {
99
+ fs.mkdirSync(path.dirname(file), { recursive: true });
100
+ fs.writeFileSync(file, JSON.stringify(payload), { mode: LOCK_FILE_MODE });
101
+ }
102
+
103
+ /**
104
+ * Atomically create the lock with O_EXCL and write `payload`. Returns true when
105
+ * we won the create, false on EEXIST (a concurrent contender holds it); other
106
+ * errors rethrow. The O_EXCL create is the single atomic arbiter — of N racers
107
+ * attempting it against the same absent path, exactly one succeeds.
108
+ */
109
+ function tryExclusiveCreate(file, payload) {
110
+ let fd;
111
+ try {
112
+ fd = fs.openSync(file, 'wx', LOCK_FILE_MODE);
113
+ } catch (err) {
114
+ if (err.code === 'EEXIST') return false;
115
+ throw err;
116
+ }
117
+ try {
118
+ fs.writeSync(fd, JSON.stringify(payload));
119
+ } finally {
120
+ fs.closeSync(fd);
121
+ }
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Try to claim the singleton lease.
127
+ * -> { ok:true, file, token } first claim
128
+ * -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
129
+ * -> { ok:false, held } a LIVE, FRESH foreign owner holds it
130
+ * -> { ok:false, held } we lost the atomic takeover race
131
+ *
132
+ * `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
133
+ * a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
134
+ * re-create, so a test can simulate a competitor winning the O_EXCL create first
135
+ * (making our takeover lose).
136
+ */
137
+ function acquire(projectRoot, {
138
+ gitCommonDir,
139
+ pid = process.pid,
140
+ isAlive = pidAlive,
141
+ now = () => Date.now(),
142
+ token = crypto.randomUUID(),
143
+ onBeforeTakeover = null,
144
+ } = {}) {
145
+ const file = lockFilePath(projectRoot, { gitCommonDir });
146
+ fs.mkdirSync(path.dirname(file), { recursive: true });
147
+ const iso = new Date(now()).toISOString();
148
+ const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
149
+
150
+ // Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
151
+ if (tryExclusiveCreate(file, payload)) {
152
+ return { ok: true, file, token };
153
+ }
154
+
155
+ // A lock exists. A DIFFERENT, live, fresh owner blocks us.
156
+ const held = readLock(file);
157
+ if (held && held.pid !== pid && !isHeldStale(held, { isAlive, now })) {
158
+ return { ok: false, held };
159
+ }
160
+
161
+ // Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
162
+ // remove the stale lock, then re-create it with O_EXCL. If a competitor
163
+ // recreated it first, our exclusive create fails (EEXIST) and we back off — so
164
+ // two racers reclaiming the same stale lock can NEVER both win. A wedged owner
165
+ // that revives after we delete its lock is stopped by the per-lease `token`
166
+ // guard on stamp()/updateWatchers()/release(), never able to resurrect it here.
167
+ fs.rmSync(file, { force: true });
168
+ if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
169
+ if (!tryExclusiveCreate(file, payload)) {
170
+ return { ok: false, held: readLock(file) };
171
+ }
172
+ return { ok: true, file, token, reclaimed: true };
173
+ }
174
+
175
+ /**
176
+ * Refresh `heartbeatAt` on OUR lock. Returns false (a no-op) when the lock is
177
+ * missing or NOT ours by `token` — we never stamp a lease we no longer own, so a
178
+ * revived wedged owner cannot resurrect a reclaimed lease.
179
+ */
180
+ function stamp(projectRoot, { gitCommonDir, token, pid = process.pid, now = () => Date.now() } = {}) {
181
+ const file = lockFilePath(projectRoot, { gitCommonDir });
182
+ const held = readLock(file);
183
+ if (!held || !ownsLock(held, { token, pid })) return false;
184
+ held.heartbeatAt = new Date(now()).toISOString();
185
+ writeLock(file, held);
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * Start a heartbeat timer that stamps `heartbeatAt` every STALE_MS/3. The timer
191
+ * is `.unref()`ed so it never keeps the process alive. `opts` MUST carry the
192
+ * `token` returned by `acquire` (threaded straight through to `stamp`). Returns
193
+ * the handle for `stopHeartbeat`.
194
+ */
195
+ function startHeartbeat(projectRoot, opts = {}) {
196
+ const timer = setInterval(() => stamp(projectRoot, opts), Math.max(1, Math.floor(STALE_MS / 3)));
197
+ if (typeof timer.unref === 'function') timer.unref();
198
+ return timer;
199
+ }
200
+
201
+ /** Stop a heartbeat timer started by `startHeartbeat`. */
202
+ function stopHeartbeat(timer) {
203
+ if (timer) clearInterval(timer);
204
+ }
205
+
206
+ /**
207
+ * Rewrite the `watchers[]` array on OUR lock. Returns false when the lock is
208
+ * missing or NOT ours by `token`.
209
+ */
210
+ function updateWatchers(projectRoot, prNumbers, { gitCommonDir, token, pid = process.pid } = {}) {
211
+ const file = lockFilePath(projectRoot, { gitCommonDir });
212
+ const held = readLock(file);
213
+ if (!held || !ownsLock(held, { token, pid })) return false;
214
+ held.watchers = Array.isArray(prNumbers) ? prNumbers : [];
215
+ writeLock(file, held);
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Release the lease — delete the lock ONLY when it is ours by `token`, so a
221
+ * foreign or already-reclaimed lock is never removed out from under its owner
222
+ * (and a reused pid can never delete someone else's lease).
223
+ */
224
+ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
225
+ const file = lockFilePath(projectRoot, { gitCommonDir });
226
+ try {
227
+ const held = readLock(file);
228
+ if (held && ownsLock(held, { token, pid })) fs.rmSync(file, { force: true });
229
+ } catch {
230
+ /* best effort — a failed release just leaves a stale lock to be reclaimed */
231
+ }
232
+ }
233
+
234
+ /** True only while this exact pid+token still owns the shared lease. */
235
+ function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
236
+ const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
237
+ return Boolean(held && ownsLock(held, { token, pid }));
238
+ }
239
+
240
+ module.exports = {
241
+ STALE_MS,
242
+ lockFilePath,
243
+ pidAlive,
244
+ ownsLock,
245
+ acquire,
246
+ stamp,
247
+ startHeartbeat,
248
+ stopHeartbeat,
249
+ updateWatchers,
250
+ release,
251
+ owns,
252
+ };
@@ -20,6 +20,7 @@ const path = require('node:path');
20
20
  const { spawn, execFileSync } = require('node:child_process');
21
21
 
22
22
  const journal = require('./journal');
23
+ const brokerMod = require('../kernel/broker');
23
24
 
24
25
  /** Absolute path to the forge CLI entrypoint (this file is lib/pr-monitor/). */
25
26
  function forgeBin() {
@@ -35,7 +36,7 @@ function forgeBin() {
35
36
  function defaultResolveSlug({ cwd, exec = execFileSync }) {
36
37
  try {
37
38
  const url = exec('git', ['remote', 'get-url', 'origin'], {
38
- cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
39
+ cwd, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
39
40
  }).trim();
40
41
  const match = /[/:][^/]+\/([^/]+?)(?:\.git)?$/.exec(url);
41
42
  return match ? match[1] : null;
@@ -66,7 +67,18 @@ function startPrWatcherDetached(opts = {}) {
66
67
 
67
68
  const slug = resolveSlug({ cwd, exec: opts.exec });
68
69
  if (slug) {
69
- const dir = journalMod.journalDir({ root: cwd, repo: slug, pr: prNumber });
70
+ let gitCommonDir = opts.gitCommonDir;
71
+ if (!gitCommonDir) {
72
+ try {
73
+ const resolveGitCommonDir = opts.resolveGitCommonDir || brokerMod.resolveGitCommonDir;
74
+ gitCommonDir = resolveGitCommonDir(cwd, { warn: () => {} });
75
+ } catch {
76
+ /* unavailable common-dir keeps the legacy per-root journal fallback */
77
+ }
78
+ }
79
+ const dir = journalMod.journalDir({
80
+ root: cwd, gitCommonDir, repo: slug, pr: prNumber,
81
+ });
70
82
  if (journalMod.watcherRunning(dir)) return { started: false, reason: 'already-running' };
71
83
  }
72
84