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

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 +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
Binary file
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+
3
+ const { normalizeEvidenceText } = require('../review-adapter');
4
+
5
+ /**
6
+ * PR-monitor Actions-summary renderer. It turns one read-only
7
+ * `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
8
+ * the workflow's `GITHUB_STEP_SUMMARY` surface.
9
+ *
10
+ * This is presentation only: it displays the canonical verdict supplied by
11
+ * `forge shepherd <pr> --pull --json`, lists unresolved review threads and CI
12
+ * state, and never merges or resolves anything. The `pr-verdict:*` label is a
13
+ * cheap visibility projection of that same verdict, not merge authority.
14
+ *
15
+ * @module pr-monitor/render-summary
16
+ */
17
+
18
+ /**
19
+ * Presentation-only headline for each canonical merge verdict (lib/pr-pull.js).
20
+ * The verdict is computed once by pr-pull and passed in; this map only decides
21
+ * how it is displayed, so there is no second verdict ladder to drift.
22
+ */
23
+ const VERDICT_HEADLINE = {
24
+ INCOMPLETE: '⚪ **Verdict: `INCOMPLETE`** — bounded review/check evidence is incomplete; merge readiness blocked (fail-closed).',
25
+ UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
26
+ 'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
27
+ BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
28
+ 'BLOCKED-CHECKS': '🔴 **Verdict: `blocked-checks`** — a required check is failing/missing; fix it.',
29
+ 'BLOCKED-THREADS': '🟠 **Verdict: `blocked-threads`** — unresolved review threads need addressing.',
30
+ 'REVIEW-PENDING': '🟡 **Verdict: `review-pending`** — awaiting review / settle window; not ready yet.',
31
+ 'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
32
+ };
33
+
34
+ const MAX_SUMMARY_THREADS = 64;
35
+ const MAX_SUMMARY_CHECKS = 20;
36
+ const MAX_SUMMARY_AUTHORS = 12;
37
+ const MAX_SUMMARY_TEXT_CHARS = 256;
38
+ const MAX_SUMMARY_SIGNALS = 16;
39
+ const MAX_SUMMARY_CHARS = 32768;
40
+
41
+ /** Render the one-line headline for a canonical verdict, failing closed. */
42
+ function verdictHeadline(verdict) {
43
+ return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
44
+ }
45
+
46
+ /**
47
+ * Render untrusted text as a Markdown code span without allowing its backticks
48
+ * or line breaks to change the surrounding summary structure.
49
+ *
50
+ * CommonMark permits a code span to use more than one backtick. Pick a fence
51
+ * longer than every run in the value, and flatten CR/LF so the summary stays
52
+ * one line per diagnostic.
53
+ */
54
+ function mdCode(value) {
55
+ const text = normalizeEvidenceText(value, { maxChars: MAX_SUMMARY_TEXT_CHARS });
56
+ let longestRun = 0;
57
+ let currentRun = 0;
58
+ for (const character of text) {
59
+ if (character === '`') {
60
+ currentRun += 1;
61
+ longestRun = Math.max(longestRun, currentRun);
62
+ } else {
63
+ currentRun = 0;
64
+ }
65
+ }
66
+ const fence = '`'.repeat(longestRun + 1);
67
+ const content = text.startsWith('`') || text.endsWith('`') ? ` ${text} ` : text;
68
+ return `${fence}${content}${fence}`;
69
+ }
70
+
71
+ /** Cap threads listed per author so a noisy PR cannot produce an enormous summary. */
72
+ const MAX_THREADS_PER_AUTHOR = 8;
73
+
74
+ /** Group unresolved review-thread comments by author in deterministic order. */
75
+ function groupByAuthor(comments) {
76
+ const byAuthor = new Map();
77
+ for (const comment of (Array.isArray(comments) ? comments : [])) {
78
+ const author = normalizeEvidenceText(comment.author || 'unknown', { maxChars: MAX_SUMMARY_TEXT_CHARS });
79
+ if (!byAuthor.has(author)) byAuthor.set(author, []);
80
+ byAuthor.get(author).push(comment);
81
+ }
82
+ return [...byAuthor.entries()].sort((a, b) => {
83
+ if (b[1].length !== a[1].length) return b[1].length - a[1].length;
84
+ if (a[0] < b[0]) return -1;
85
+ if (a[0] > b[0]) return 1;
86
+ return 0;
87
+ });
88
+ }
89
+
90
+ /** One-line locator for a thread: `path:line` when known, else its id. */
91
+ function threadLocator(thread) {
92
+ if (thread.path) {
93
+ const path = normalizeEvidenceText(thread.path, { maxChars: MAX_SUMMARY_TEXT_CHARS });
94
+ return thread.line != null ? `${path}:${thread.line}` : path;
95
+ }
96
+ return normalizeEvidenceText(thread.threadId || '(thread)', { maxChars: MAX_SUMMARY_TEXT_CHARS });
97
+ }
98
+
99
+ function validThread(thread) {
100
+ if (!thread || typeof thread !== 'object' || Array.isArray(thread)
101
+ || typeof thread.author !== 'string' || !thread.author.trim()) return false;
102
+ const hasPath = typeof thread.path === 'string' && thread.path.trim();
103
+ const hasId = typeof thread.threadId === 'string' && thread.threadId.trim();
104
+ return Boolean(hasPath || hasId);
105
+ }
106
+
107
+ function validCheck(check) {
108
+ return Boolean(check && typeof check === 'object' && !Array.isArray(check)
109
+ && typeof check.name === 'string' && check.name.trim());
110
+ }
111
+
112
+ /** Render unresolved review threads, preserving fail-closed availability. */
113
+ function renderThreads(bundle, lines) {
114
+ // Empty arrays are ambiguous when the adapter could not read comments. Only
115
+ // an explicit available:true read may report zero unresolved threads.
116
+ if (bundle.unresolvedCommentsAvailable !== true) {
117
+ const why = bundle.unresolvedCommentsError || 'thread read unavailable (capability absent)';
118
+ lines.push('### Review threads');
119
+ lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
120
+ lines.push('');
121
+ return 'INCOMPLETE';
122
+ }
123
+
124
+ if (!Array.isArray(bundle.unresolvedComments)
125
+ || bundle.unresolvedComments.slice(0, MAX_SUMMARY_THREADS + 1).some((thread) => !validThread(thread))) {
126
+ lines.push(
127
+ '### Review threads',
128
+ '⚠️ Review thread evidence was **malformed** this pass — not treated as zero.',
129
+ ''
130
+ );
131
+ return 'INCOMPLETE';
132
+ }
133
+
134
+ const comments = bundle.unresolvedComments;
135
+ const incomplete = comments.length > MAX_SUMMARY_THREADS;
136
+ if (comments.length === 0) {
137
+ lines.push('### Review threads');
138
+ lines.push('✅ No unresolved review threads.');
139
+ lines.push('');
140
+ return 'ZERO';
141
+ }
142
+
143
+ const boundedComments = comments.slice(0, MAX_SUMMARY_THREADS);
144
+ const groups = groupByAuthor(boundedComments).slice(0, MAX_SUMMARY_AUTHORS);
145
+ lines.push(`### Unresolved review threads (${comments.length})`);
146
+ lines.push('');
147
+ for (const [author, threads] of groups) {
148
+ lines.push(`- **${mdCode(author)}** — ${threads.length}`);
149
+ for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
150
+ lines.push(` - ${mdCode(threadLocator(thread))}`);
151
+ }
152
+ if (threads.length > MAX_THREADS_PER_AUTHOR) {
153
+ lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
154
+ }
155
+ }
156
+ if (comments.length > boundedComments.length) {
157
+ lines.push(`- …and ${comments.length - boundedComments.length} more thread(s) omitted by the bounded summary`);
158
+ }
159
+ const representedThreads = groups.reduce((total, [, threads]) => total + threads.length, 0);
160
+ if (representedThreads < boundedComments.length) {
161
+ lines.push(`- …and ${boundedComments.length - representedThreads} thread(s) from additional authors`);
162
+ }
163
+ lines.push('');
164
+ return incomplete ? 'INCOMPLETE' : 'OPEN';
165
+ }
166
+
167
+ /** Render failing and pending checks, preserving fail-closed availability. */
168
+ function renderChecks(bundle, lines) {
169
+ // Only ciAvailable:true permits a clean-check claim. Missing or false means
170
+ // the read did not complete, so empty arrays must not look green.
171
+ if (bundle.ciAvailable !== true) {
172
+ lines.push('### Checks');
173
+ lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
174
+ lines.push('');
175
+ return false;
176
+ }
177
+
178
+ const ci = bundle.ci || {};
179
+ const checkArrays = [ci.checks, ci.failing, ci.pending];
180
+ if (checkArrays.some((checks) => !Array.isArray(checks))
181
+ || checkArrays.some((checks) => checks.slice(0, MAX_SUMMARY_CHECKS + 1)
182
+ .some((check) => !validCheck(check)))) {
183
+ lines.push(
184
+ '### Checks',
185
+ '⚠️ Check evidence was **malformed** this pass — not treated as green.',
186
+ ''
187
+ );
188
+ return false;
189
+ }
190
+ const failing = ci.failing;
191
+ const pending = ci.pending;
192
+ const incomplete = checkArrays.some((checks) => checks.length > MAX_SUMMARY_CHECKS);
193
+ lines.push('### Checks');
194
+ if (failing.length === 0 && pending.length === 0) {
195
+ lines.push('✅ No failing or pending checks.');
196
+ } else {
197
+ if (failing.length > 0) {
198
+ lines.push(`- ❌ **Failing (${failing.length}):** ${failing.slice(0, MAX_SUMMARY_CHECKS).map((check) => mdCode(check.name || '?')).join(', ')}`);
199
+ if (failing.length > MAX_SUMMARY_CHECKS) {
200
+ lines.push(` - …and ${failing.length - MAX_SUMMARY_CHECKS} more failing check(s)`);
201
+ }
202
+ }
203
+ if (pending.length > 0) {
204
+ lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.slice(0, MAX_SUMMARY_CHECKS).map((check) => mdCode(check.name || '?')).join(', ')}`);
205
+ if (pending.length > MAX_SUMMARY_CHECKS) {
206
+ lines.push(` - …and ${pending.length - MAX_SUMMARY_CHECKS} more pending check(s)`);
207
+ }
208
+ }
209
+ }
210
+ lines.push('');
211
+ return !incomplete;
212
+ }
213
+
214
+ /**
215
+ * Render the PR monitor's Actions job summary.
216
+ *
217
+ * @param {object} bundle - a `gatherPrBundle` result (lib/pr-bundle.js)
218
+ * @param {object} [opts]
219
+ * @param {Date} [opts.now] - injected clock for deterministic output
220
+ * @param {string} [opts.verdict] - canonical `--pull` verdict
221
+ * @param {string[]} [opts.unreadable] - unreadable signal names from `--pull`
222
+ * @param {string|number} [opts.pr] - PR number for the CLI diagnostics hint
223
+ * @returns {{ body: string }}
224
+ */
225
+ function renderSummary(bundle = {}, opts = {}) {
226
+ const now = opts.now instanceof Date ? opts.now : new Date();
227
+ const lines = ['## 🔭 Forge PR Monitor', ''];
228
+ const verdict = String(opts.verdict || '').toUpperCase();
229
+ const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
230
+ const unreadable = Array.isArray(opts.unreadable)
231
+ ? opts.unreadable.filter(Boolean).slice(0, MAX_SUMMARY_SIGNALS)
232
+ : [];
233
+
234
+ const headlineIndex = lines.length;
235
+ lines.push(verdictHeadline(opts.verdict));
236
+ if (isUnknown && unreadable.length > 0) {
237
+ lines.push('');
238
+ lines.push(`> Unreadable signal(s): ${unreadable.map((signal) => mdCode(signal)).join(', ')}.`);
239
+ }
240
+ lines.push('');
241
+ 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._');
242
+ lines.push('');
243
+
244
+ let threadState = renderThreads(bundle, lines);
245
+ const checksComplete = renderChecks(bundle, lines);
246
+
247
+ const branch = bundle.branch || {};
248
+ if ((branch.behind || 0) > 0) {
249
+ lines.push(`> Branch is **${branch.behind}** commit(s) behind base.`);
250
+ lines.push('');
251
+ }
252
+
253
+ const pr = opts.pr || bundle.pr || '<pr>';
254
+ const detailCommand = mdCode(`forge shepherd ${pr} --pull --json`);
255
+ lines.push(
256
+ '---',
257
+ `Detailed JSON: ${detailCommand}`,
258
+ `_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`
259
+ );
260
+
261
+ let evidenceStatus = threadState === 'INCOMPLETE' || !checksComplete ? 'INCOMPLETE' : 'COMPLETE';
262
+ if (evidenceStatus === 'INCOMPLETE') lines[headlineIndex] = verdictHeadline('INCOMPLETE');
263
+ let body = lines.join('\n');
264
+ if (body.length > MAX_SUMMARY_CHARS) {
265
+ threadState = 'INCOMPLETE';
266
+ evidenceStatus = 'INCOMPLETE';
267
+ body = [
268
+ '## 🔭 Forge PR Monitor',
269
+ '',
270
+ verdictHeadline('INCOMPLETE'),
271
+ '',
272
+ '⚠️ Summary evidence exceeded the bounded rendering limit; inspect the detailed JSON.',
273
+ '',
274
+ '_This monitor does not merge and never resolves review threads._',
275
+ '',
276
+ `Detailed JSON: ${detailCommand}`,
277
+ `_Updated ${now.toISOString()} · summary-only monitor · labels state, never merges, never resolves threads._`,
278
+ ].join('\n');
279
+ }
280
+
281
+ return { body, evidenceStatus, threadState };
282
+ }
283
+
284
+ module.exports = {
285
+ MAX_SUMMARY_CHARS,
286
+ MAX_SUMMARY_CHECKS,
287
+ MAX_SUMMARY_THREADS,
288
+ renderSummary,
289
+ verdictHeadline,
290
+ groupByAuthor,
291
+ threadLocator,
292
+ MAX_THREADS_PER_AUTHOR,
293
+ };
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ const preflightCommand = require('../commands/preflight');
6
+
7
+ const MAX_FINDINGS = 20;
8
+ const MAX_FINDING_CHARS = 500;
9
+
10
+ function boundedLines(value) {
11
+ return String(value || '')
12
+ .split(/\r?\n/)
13
+ .map(line => line.trim())
14
+ .filter(Boolean)
15
+ .slice(0, MAX_FINDINGS)
16
+ .map(line => line.slice(0, MAX_FINDING_CHARS));
17
+ }
18
+
19
+ async function defaultProbeCodeRabbit({ projectRoot }, exec = execFileSync) {
20
+ try {
21
+ exec('coderabbit', ['--version'], {
22
+ cwd: projectRoot,
23
+ encoding: 'utf8',
24
+ timeout: 10_000,
25
+ stdio: ['ignore', 'pipe', 'pipe'],
26
+ windowsHide: true,
27
+ });
28
+ return { available: true };
29
+ } catch (error) {
30
+ const reason = error?.code === 'ENOENT' ? 'CLI not installed' : 'CLI unavailable or unauthenticated';
31
+ return { available: false, reason };
32
+ }
33
+ }
34
+
35
+ function parseCodeRabbitEvents(output) {
36
+ const rawLines = String(output || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
37
+ const events = [];
38
+ let malformed = rawLines.length === 0;
39
+ for (const line of rawLines) {
40
+ try {
41
+ const event = JSON.parse(line);
42
+ if (!event || typeof event !== 'object' || Array.isArray(event)) malformed = true;
43
+ else events.push(event);
44
+ } catch {
45
+ malformed = true;
46
+ }
47
+ }
48
+ return { events, malformed };
49
+ }
50
+
51
+ async function defaultRunCodeRabbit({ projectRoot, base, baseCommit }, exec = execFileSync) {
52
+ try {
53
+ const output = exec('coderabbit', ['review', '--agent', '--base', baseCommit || base], {
54
+ cwd: projectRoot,
55
+ encoding: 'utf8',
56
+ timeout: 180_000,
57
+ maxBuffer: 1024 * 1024,
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ windowsHide: true,
60
+ });
61
+ const { events, malformed } = parseCodeRabbitEvents(output);
62
+ const terminal = events.at(-1);
63
+ const terminalValid = terminal?.type === 'complete'
64
+ && ['review_completed', 'review_skipped'].includes(terminal.status)
65
+ && Number.isSafeInteger(terminal.findings) && terminal.findings >= 0;
66
+ const findingEvents = events.filter(event => event.type === 'finding');
67
+ const errorEvents = events.filter(event => event.type === 'error');
68
+ const clean = !malformed && terminalValid && terminal.findings === 0
69
+ && findingEvents.length === 0 && errorEvents.length === 0;
70
+ const findings = findingEvents.slice(0, MAX_FINDINGS).map(event => String(
71
+ event.codegenInstructions || event.message || 'CodeRabbit finding',
72
+ ).slice(0, MAX_FINDING_CHARS));
73
+ if (!clean && findings.length === 0) {
74
+ let detail = `CodeRabbit reported ${terminal?.findings || errorEvents.length} finding(s)`;
75
+ if (malformed || !terminalValid) {
76
+ detail = 'CodeRabbit agent output was malformed or incomplete';
77
+ }
78
+ findings.push(detail);
79
+ }
80
+ return {
81
+ ok: clean,
82
+ summary: clean ? 'local review completed with no findings' : `${findings.length} local review finding(s)`,
83
+ findings: clean ? [] : findings,
84
+ };
85
+ } catch (error) {
86
+ const output = `${error?.stdout || ''}\n${error?.stderr || ''}`;
87
+ const findings = boundedLines(output);
88
+ if (/unauthori[sz]ed|authentication|not authenticated|log in|login required/i.test(output)) {
89
+ return { ok: false, unavailable: true, summary: 'CLI authentication unavailable', findings: [] };
90
+ }
91
+ return {
92
+ ok: false,
93
+ summary: findings[0] || 'local review failed',
94
+ findings,
95
+ };
96
+ }
97
+ }
98
+
99
+ async function defaultRunDeterministic({ projectRoot, base, baseCommit, baseRef: resolvedBaseRef }, exec = execFileSync) {
100
+ const baseRef = baseCommit || resolvedBaseRef || (String(base || '').startsWith('origin/')
101
+ ? String(base)
102
+ : `origin/${String(base || '')}`);
103
+ return preflightCommand.handler([], {}, projectRoot, {
104
+ log: () => {},
105
+ resolveChangeSet: ({ runAll }) => preflightCommand.resolveChangeSet(exec, { runAll, baseRef }),
106
+ });
107
+ }
108
+
109
+ async function defaultResolveBaseCommit({ projectRoot, base, baseRef }, exec = execFileSync) {
110
+ const ref = baseRef || (String(base || '').startsWith('origin/')
111
+ ? String(base)
112
+ : `origin/${String(base || '')}`);
113
+ const resolved = String(exec('git', ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
114
+ cwd: projectRoot,
115
+ encoding: 'utf8',
116
+ timeout: 10_000,
117
+ stdio: ['ignore', 'pipe', 'pipe'],
118
+ windowsHide: true,
119
+ }) || '').trim().toLowerCase();
120
+ if (!/^[0-9a-f]{40}$/.test(resolved)) throw new Error(`PR base commit is unavailable for ${ref}`);
121
+ return resolved;
122
+ }
123
+
124
+ function providerResult(status, ok, summary, findings = []) {
125
+ return { status, ok, summary, findings: findings.slice(0, MAX_FINDINGS) };
126
+ }
127
+
128
+ function deterministicFindings(results) {
129
+ return (Array.isArray(results) ? results : [])
130
+ .filter(result => result?.ok === false && !result.skipped)
131
+ .slice(0, MAX_FINDINGS)
132
+ .map(result => ({
133
+ provider: String(result.name || 'deterministic-gate').slice(0, 80),
134
+ detail: String(result.summary || 'gate failed').slice(0, MAX_FINDING_CHARS),
135
+ }));
136
+ }
137
+
138
+ function notApplicableResult(context) {
139
+ const expectedHead = typeof context.expectedHead === 'string'
140
+ && /^[0-9a-f]{40}$/i.test(context.expectedHead) ? context.expectedHead.toLowerCase() : null;
141
+ const localHead = typeof context.localHead === 'string' ? context.localHead.toLowerCase() : null;
142
+ const exactHeadAvailable = expectedHead !== null;
143
+ const exactHeadCheckedOut = exactHeadAvailable && localHead === expectedHead;
144
+ if (exactHeadCheckedOut && context.cleanTree === true) return null;
145
+ const dirtyCheckout = exactHeadCheckedOut && context.cleanTree !== true;
146
+ const summary = dirtyCheckout
147
+ ? 'local checkout has uncommitted changes'
148
+ : (exactHeadAvailable ? 'local checkout is not the PR head' : 'PR head is unavailable');
149
+ return {
150
+ status: 'INCOMPLETE',
151
+ blocking: true,
152
+ providers: {
153
+ coderabbit: providerResult(
154
+ 'NOT_APPLICABLE',
155
+ false,
156
+ summary,
157
+ ),
158
+ deterministic: { status: 'NOT_APPLICABLE', ok: false, results: [] },
159
+ },
160
+ findings: [{ provider: 'local-preflight', detail: summary }],
161
+ };
162
+ }
163
+
164
+ async function codeRabbitResult(context, probeCodeRabbit, runCodeRabbit) {
165
+ const probe = await probeCodeRabbit(context);
166
+ if (probe?.available !== true) {
167
+ return providerResult('UNAVAILABLE', false, probe?.reason || 'CLI unavailable');
168
+ }
169
+ const review = await runCodeRabbit(context);
170
+ if (review?.unavailable === true) {
171
+ return providerResult('UNAVAILABLE', false, review.summary || 'CLI unavailable');
172
+ }
173
+ return providerResult(
174
+ review?.ok === true ? 'PASS' : 'FAIL',
175
+ review?.ok === true,
176
+ review?.summary || 'local review completed',
177
+ Array.isArray(review?.findings) ? review.findings : [],
178
+ );
179
+ }
180
+
181
+ async function deterministicResult(context, runDeterministic) {
182
+ try {
183
+ return await runDeterministic(context);
184
+ } catch (error) {
185
+ return { success: false, results: [], reason: error?.message || String(error) };
186
+ }
187
+ }
188
+
189
+ async function runLocalReviewPreflight(context, deps = {}) {
190
+ const probeCodeRabbit = deps.probeCodeRabbit || defaultProbeCodeRabbit;
191
+ const runCodeRabbit = deps.runCodeRabbit || defaultRunCodeRabbit;
192
+ const runDeterministic = deps.runDeterministic || defaultRunDeterministic;
193
+
194
+ const notApplicable = notApplicableResult(context);
195
+ if (notApplicable) return notApplicable;
196
+
197
+ let reviewContext = context;
198
+ const suppliedBaseCommit = typeof context.baseCommit === 'string'
199
+ && /^[0-9a-f]{40}$/i.test(context.baseCommit);
200
+ const shouldResolveBase = deps.resolveBaseCommit !== false && !suppliedBaseCommit;
201
+ if (shouldResolveBase) {
202
+ const resolveBaseCommit = typeof deps.resolveBaseCommit === 'function'
203
+ ? deps.resolveBaseCommit
204
+ : defaultResolveBaseCommit;
205
+ try {
206
+ const baseCommit = String(await resolveBaseCommit(context) || '').trim().toLowerCase();
207
+ if (!/^[0-9a-f]{40}$/.test(baseCommit)) throw new Error('PR base commit resolver returned an invalid commit');
208
+ reviewContext = { ...context, baseCommit };
209
+ } catch (error) {
210
+ const detail = error?.message || String(error);
211
+ return {
212
+ status: 'INCOMPLETE',
213
+ blocking: true,
214
+ providers: {
215
+ coderabbit: providerResult('NOT_APPLICABLE', false, detail),
216
+ deterministic: { status: 'NOT_APPLICABLE', ok: false, results: [] },
217
+ },
218
+ findings: [{ provider: 'local-preflight', detail }],
219
+ };
220
+ }
221
+ } else if (suppliedBaseCommit) {
222
+ reviewContext = { ...context, baseCommit: context.baseCommit.toLowerCase() };
223
+ }
224
+
225
+ const coderabbit = await codeRabbitResult(reviewContext, probeCodeRabbit, runCodeRabbit);
226
+ const deterministic = await deterministicResult(reviewContext, runDeterministic);
227
+ const gateResults = Array.isArray(deterministic?.results) ? deterministic.results : [];
228
+ const deterministicOk = deterministic?.success === true;
229
+ const findings = [
230
+ ...coderabbit.findings.map(detail => ({ provider: 'coderabbit', detail })),
231
+ ...deterministicFindings(gateResults),
232
+ ].slice(0, MAX_FINDINGS);
233
+ if (!deterministicOk && findings.length === 0) {
234
+ findings.push({
235
+ provider: 'deterministic-gates',
236
+ detail: String(deterministic?.reason || 'preflight failed').slice(0, MAX_FINDING_CHARS),
237
+ });
238
+ }
239
+
240
+ const failed = !deterministicOk || coderabbit.status === 'FAIL';
241
+ const incomplete = coderabbit.status === 'UNAVAILABLE';
242
+ const blocking = failed || incomplete;
243
+ let status = 'PASS';
244
+ if (failed) status = 'FAIL';
245
+ else if (incomplete) status = 'INCOMPLETE';
246
+ return {
247
+ status,
248
+ blocking,
249
+ providers: {
250
+ coderabbit,
251
+ deterministic: {
252
+ status: deterministicOk ? 'PASS' : 'FAIL',
253
+ ok: deterministicOk,
254
+ results: gateResults,
255
+ },
256
+ },
257
+ findings,
258
+ };
259
+ }
260
+
261
+ module.exports = {
262
+ MAX_FINDINGS,
263
+ boundedLines,
264
+ defaultProbeCodeRabbit,
265
+ defaultResolveBaseCommit,
266
+ defaultRunCodeRabbit,
267
+ defaultRunDeterministic,
268
+ runLocalReviewPreflight,
269
+ };
@@ -16,11 +16,12 @@
16
16
  * one lock. This module is the lease PRIMITIVE only: pure fs + I/O, no spawned
17
17
  * process and no reconcile loop (the daemon wires those up later).
18
18
  *
19
- * Payload JSON: `{ pid, token, startedAt, heartbeatAt, watchers: [prNumbers] }`.
19
+ * Payload JSON: `{ pid, token, startedAt, heartbeatAt }`.
20
+ * Per-PR watcher authority lives only in Kernel owner rows.
20
21
  *
21
22
  * ## Ownership is by TOKEN, not pid
22
23
  * Each successful `acquire` mints a unique `token`. Every mutating op
23
- * (`stamp`/`updateWatchers`/`release`) verifies that token against the on-disk
24
+ * (`stamp`/`release`) verifies that token against the on-disk
24
25
  * lock before writing. A pid can be reused after a crash/reboot, and a wedged
25
26
  * owner can revive after its lease was reclaimed — in both cases the token no
26
27
  * longer matches, so the superseded holder can never resurrect or mutate a lease
@@ -37,7 +38,9 @@ const crypto = require('node:crypto');
37
38
  const { resolveGitCommonDir } = require('../kernel/broker');
38
39
 
39
40
  /** A wedged owner whose heartbeat is older than this (ms) is reclaimable. */
40
- const STALE_MS = 30000;
41
+ // Must exceed the longest synchronous daemon read (`gh pr list`, 30s) with
42
+ // enough margin that a blocked event loop cannot be reclaimed mid-read.
43
+ const STALE_MS = 90000;
41
44
  const LOCK_FILE_MODE = 0o600;
42
45
 
43
46
  /**
@@ -126,6 +129,9 @@ function tryExclusiveCreate(file, payload) {
126
129
  * -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
127
130
  * -> { ok:false, held } a LIVE, FRESH foreign owner holds it
128
131
  * -> { ok:false, held } we lost the atomic takeover race
132
+ * -> { ok:false, held:null, legacyMigrationPending:true, reason:'legacy-lease-unreadable' }
133
+ * the lock exists but cannot be parsed —
134
+ * never reclaimed, never deleted here
129
135
  *
130
136
  * `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
131
137
  * a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
@@ -139,11 +145,12 @@ function acquire(projectRoot, {
139
145
  now = () => Date.now(),
140
146
  token = crypto.randomUUID(),
141
147
  onBeforeTakeover = null,
148
+ preserveLegacy = false,
142
149
  } = {}) {
143
150
  const file = lockFilePath(projectRoot, { gitCommonDir });
144
151
  fs.mkdirSync(path.dirname(file), { recursive: true });
145
152
  const iso = new Date(now()).toISOString();
146
- const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
153
+ const payload = { pid, token, startedAt: iso, heartbeatAt: iso };
147
154
 
148
155
  // Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
149
156
  if (tryExclusiveCreate(file, payload)) {
@@ -156,15 +163,33 @@ function acquire(projectRoot, {
156
163
  return { ok: false, held };
157
164
  }
158
165
 
166
+ // NO caller may discard an unreadable lease: its bytes may be the only
167
+ // evidence of legacy watchers, and the daemon acquires HERE but migrates
168
+ // LATER — so an opt-in guard would still delete that evidence before
169
+ // migration could read it. This is unconditional at the shared acquisition
170
+ // boundary: fail closed, leave the bytes exactly as found, and surface a
171
+ // reason a caller can act on. Recovery belongs to migration, never to
172
+ // acquisition.
173
+ if (fs.existsSync(file) && !held) {
174
+ return {
175
+ ok: false, held: null, legacyMigrationPending: true, reason: 'legacy-lease-unreadable',
176
+ };
177
+ }
178
+
179
+ if (preserveLegacy && Array.isArray(held?.watchers)) {
180
+ return { ok: false, held, legacyMigrationPending: true };
181
+ }
182
+
159
183
  // Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
160
184
  // remove the stale lock, then re-create it with O_EXCL. If a competitor
161
185
  // recreated it first, our exclusive create fails (EEXIST) and we back off — so
162
186
  // two racers reclaiming the same stale lock can NEVER both win. A wedged owner
163
187
  // 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.
188
+ // guard on stamp()/release(), never able to resurrect it here.
165
189
  fs.rmSync(file, { force: true });
166
190
  if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
167
- if (!tryExclusiveCreate(file, payload)) {
191
+ const takeoverPayload = Array.isArray(held?.watchers) ? { ...payload, watchers: held.watchers } : payload;
192
+ if (!tryExclusiveCreate(file, takeoverPayload)) {
168
193
  return { ok: false, held: readLock(file) };
169
194
  }
170
195
  return { ok: true, file, token, reclaimed: true };
@@ -201,19 +226,6 @@ function stopHeartbeat(timer) {
201
226
  if (timer) clearInterval(timer);
202
227
  }
203
228
 
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
229
  /**
218
230
  * Release the lease — delete the lock ONLY when it is ours by `token`, so a
219
231
  * foreign or already-reclaimed lock is never removed out from under its owner
@@ -229,6 +241,12 @@ function release(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
229
241
  }
230
242
  }
231
243
 
244
+ /** True only while this exact pid+token still owns the shared lease. */
245
+ function owns(projectRoot, { gitCommonDir, token, pid = process.pid } = {}) {
246
+ const held = readLock(lockFilePath(projectRoot, { gitCommonDir }));
247
+ return Boolean(held && ownsLock(held, { token, pid }));
248
+ }
249
+
232
250
  module.exports = {
233
251
  STALE_MS,
234
252
  lockFilePath,
@@ -238,6 +256,6 @@ module.exports = {
238
256
  stamp,
239
257
  startHeartbeat,
240
258
  stopHeartbeat,
241
- updateWatchers,
242
259
  release,
260
+ owns,
243
261
  };