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

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
Binary file
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { normalizeEvidenceText } = require('../review-adapter');
4
+
3
5
  /**
4
6
  * PR-monitor Actions-summary renderer. It turns one read-only
5
7
  * `gatherPrBundle` result (lib/pr-bundle.js) into deterministic Markdown for
@@ -19,6 +21,7 @@
19
21
  * how it is displayed, so there is no second verdict ladder to drift.
20
22
  */
21
23
  const VERDICT_HEADLINE = {
24
+ INCOMPLETE: '⚪ **Verdict: `INCOMPLETE`** — bounded review/check evidence is incomplete; merge readiness blocked (fail-closed).',
22
25
  UNKNOWN: '⚪ **Verdict: `unknown`** — a signal was unreadable; state unconfirmed (fail-closed).',
23
26
  'BLOCKED-CONFLICT': '🔀 **Verdict: `blocked-conflict`** — branch conflicts with base; rebase/merge and resolve.',
24
27
  BEHIND: '⬇️ **Verdict: `behind`** — branch is behind base; update/rebase (protection requires up-to-date).',
@@ -28,6 +31,13 @@ const VERDICT_HEADLINE = {
28
31
  'CLEAN-MERGEABLE': '🟢 **Verdict: `clean-mergeable`** — green + zero unresolved threads; ready for a human to merge.',
29
32
  };
30
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
+
31
41
  /** Render the one-line headline for a canonical verdict, failing closed. */
32
42
  function verdictHeadline(verdict) {
33
43
  return VERDICT_HEADLINE[String(verdict || '').toUpperCase()] || VERDICT_HEADLINE.UNKNOWN;
@@ -42,7 +52,7 @@ function verdictHeadline(verdict) {
42
52
  * one line per diagnostic.
43
53
  */
44
54
  function mdCode(value) {
45
- const text = String(value ?? '').replace(/[\r\n]+/g, ' ');
55
+ const text = normalizeEvidenceText(value, { maxChars: MAX_SUMMARY_TEXT_CHARS });
46
56
  let longestRun = 0;
47
57
  let currentRun = 0;
48
58
  for (const character of text) {
@@ -65,19 +75,38 @@ const MAX_THREADS_PER_AUTHOR = 8;
65
75
  function groupByAuthor(comments) {
66
76
  const byAuthor = new Map();
67
77
  for (const comment of (Array.isArray(comments) ? comments : [])) {
68
- const author = String(comment.author || 'unknown');
78
+ const author = normalizeEvidenceText(comment.author || 'unknown', { maxChars: MAX_SUMMARY_TEXT_CHARS });
69
79
  if (!byAuthor.has(author)) byAuthor.set(author, []);
70
80
  byAuthor.get(author).push(comment);
71
81
  }
72
- return [...byAuthor.entries()].sort(
73
- (a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]),
74
- );
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
+ });
75
88
  }
76
89
 
77
90
  /** One-line locator for a thread: `path:line` when known, else its id. */
78
91
  function threadLocator(thread) {
79
- if (thread.path) return thread.line != null ? `${thread.path}:${thread.line}` : thread.path;
80
- return thread.threadId || '(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());
81
110
  }
82
111
 
83
112
  /** Render unresolved review threads, preserving fail-closed availability. */
@@ -89,22 +118,34 @@ function renderThreads(bundle, lines) {
89
118
  lines.push('### Review threads');
90
119
  lines.push(`⚠️ Review threads were **unreadable** this pass (${mdCode(why)}) — not treated as zero. Re-run once the read recovers.`);
91
120
  lines.push('');
92
- return;
121
+ return 'INCOMPLETE';
93
122
  }
94
123
 
95
- const comments = Array.isArray(bundle.unresolvedComments) ? bundle.unresolvedComments : [];
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;
96
136
  if (comments.length === 0) {
97
137
  lines.push('### Review threads');
98
138
  lines.push('✅ No unresolved review threads.');
99
139
  lines.push('');
100
- return;
140
+ return 'ZERO';
101
141
  }
102
142
 
103
- const groups = groupByAuthor(comments);
143
+ const boundedComments = comments.slice(0, MAX_SUMMARY_THREADS);
144
+ const groups = groupByAuthor(boundedComments).slice(0, MAX_SUMMARY_AUTHORS);
104
145
  lines.push(`### Unresolved review threads (${comments.length})`);
105
146
  lines.push('');
106
147
  for (const [author, threads] of groups) {
107
- lines.push(`- **${author}** — ${threads.length}`);
148
+ lines.push(`- **${mdCode(author)}** — ${threads.length}`);
108
149
  for (const thread of threads.slice(0, MAX_THREADS_PER_AUTHOR)) {
109
150
  lines.push(` - ${mdCode(threadLocator(thread))}`);
110
151
  }
@@ -112,7 +153,15 @@ function renderThreads(bundle, lines) {
112
153
  lines.push(` - …and ${threads.length - MAX_THREADS_PER_AUTHOR} more`);
113
154
  }
114
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
+ }
115
163
  lines.push('');
164
+ return incomplete ? 'INCOMPLETE' : 'OPEN';
116
165
  }
117
166
 
118
167
  /** Render failing and pending checks, preserving fail-closed availability. */
@@ -123,24 +172,43 @@ function renderChecks(bundle, lines) {
123
172
  lines.push('### Checks');
124
173
  lines.push('⚠️ Checks were **unreadable** this pass — not treated as green. Re-run once the read recovers.');
125
174
  lines.push('');
126
- return;
175
+ return false;
127
176
  }
128
177
 
129
178
  const ci = bundle.ci || {};
130
- const failing = Array.isArray(ci.failing) ? ci.failing : [];
131
- const pending = Array.isArray(ci.pending) ? ci.pending : [];
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);
132
193
  lines.push('### Checks');
133
194
  if (failing.length === 0 && pending.length === 0) {
134
195
  lines.push('✅ No failing or pending checks.');
135
196
  } else {
136
197
  if (failing.length > 0) {
137
- lines.push(`- ❌ **Failing (${failing.length}):** ${failing.map((check) => mdCode(check.name || '?')).join(', ')}`);
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
+ }
138
202
  }
139
203
  if (pending.length > 0) {
140
- lines.push(`- ⏳ **Pending (${pending.length}):** ${pending.map((check) => mdCode(check.name || '?')).join(', ')}`);
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
+ }
141
208
  }
142
209
  }
143
210
  lines.push('');
211
+ return !incomplete;
144
212
  }
145
213
 
146
214
  /**
@@ -159,8 +227,11 @@ function renderSummary(bundle = {}, opts = {}) {
159
227
  const lines = ['## 🔭 Forge PR Monitor', ''];
160
228
  const verdict = String(opts.verdict || '').toUpperCase();
161
229
  const isUnknown = verdict === 'UNKNOWN' || !VERDICT_HEADLINE[verdict];
162
- const unreadable = Array.isArray(opts.unreadable) ? opts.unreadable.filter(Boolean) : [];
230
+ const unreadable = Array.isArray(opts.unreadable)
231
+ ? opts.unreadable.filter(Boolean).slice(0, MAX_SUMMARY_SIGNALS)
232
+ : [];
163
233
 
234
+ const headlineIndex = lines.length;
164
235
  lines.push(verdictHeadline(opts.verdict));
165
236
  if (isUnknown && unreadable.length > 0) {
166
237
  lines.push('');
@@ -170,8 +241,8 @@ function renderSummary(bundle = {}, opts = {}) {
170
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._');
171
242
  lines.push('');
172
243
 
173
- renderThreads(bundle, lines);
174
- renderChecks(bundle, lines);
244
+ let threadState = renderThreads(bundle, lines);
245
+ const checksComplete = renderChecks(bundle, lines);
175
246
 
176
247
  const branch = bundle.branch || {};
177
248
  if ((branch.behind || 0) > 0) {
@@ -180,14 +251,40 @@ function renderSummary(bundle = {}, opts = {}) {
180
251
  }
181
252
 
182
253
  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._`);
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
+ }
186
280
 
187
- return { body: lines.join('\n') };
281
+ return { body, evidenceStatus, threadState };
188
282
  }
189
283
 
190
284
  module.exports = {
285
+ MAX_SUMMARY_CHARS,
286
+ MAX_SUMMARY_CHECKS,
287
+ MAX_SUMMARY_THREADS,
191
288
  renderSummary,
192
289
  verdictHeadline,
193
290
  groupByAuthor,
@@ -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
@@ -128,6 +129,9 @@ function tryExclusiveCreate(file, payload) {
128
129
  * -> { ok:true, file, token, reclaimed } stale lock (dead/wedged owner) reclaimed
129
130
  * -> { ok:false, held } a LIVE, FRESH foreign owner holds it
130
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
131
135
  *
132
136
  * `pid`/`isAlive`/`now`/`token` are injectable for testing. `onBeforeTakeover` is
133
137
  * a test seam invoked AFTER the stale lock is removed and BEFORE our exclusive
@@ -141,11 +145,12 @@ function acquire(projectRoot, {
141
145
  now = () => Date.now(),
142
146
  token = crypto.randomUUID(),
143
147
  onBeforeTakeover = null,
148
+ preserveLegacy = false,
144
149
  } = {}) {
145
150
  const file = lockFilePath(projectRoot, { gitCommonDir });
146
151
  fs.mkdirSync(path.dirname(file), { recursive: true });
147
152
  const iso = new Date(now()).toISOString();
148
- const payload = { pid, token, startedAt: iso, heartbeatAt: iso, watchers: [] };
153
+ const payload = { pid, token, startedAt: iso, heartbeatAt: iso };
149
154
 
150
155
  // Fast path: atomic exclusive create. Only ONE caller can win O_EXCL.
151
156
  if (tryExclusiveCreate(file, payload)) {
@@ -158,15 +163,33 @@ function acquire(projectRoot, {
158
163
  return { ok: false, held };
159
164
  }
160
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
+
161
183
  // Stale (dead/wedged owner, unreadable, or already ours). Take over ATOMICALLY:
162
184
  // remove the stale lock, then re-create it with O_EXCL. If a competitor
163
185
  // recreated it first, our exclusive create fails (EEXIST) and we back off — so
164
186
  // two racers reclaiming the same stale lock can NEVER both win. A wedged owner
165
187
  // 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.
188
+ // guard on stamp()/release(), never able to resurrect it here.
167
189
  fs.rmSync(file, { force: true });
168
190
  if (typeof onBeforeTakeover === 'function') onBeforeTakeover();
169
- if (!tryExclusiveCreate(file, payload)) {
191
+ const takeoverPayload = Array.isArray(held?.watchers) ? { ...payload, watchers: held.watchers } : payload;
192
+ if (!tryExclusiveCreate(file, takeoverPayload)) {
170
193
  return { ok: false, held: readLock(file) };
171
194
  }
172
195
  return { ok: true, file, token, reclaimed: true };
@@ -203,19 +226,6 @@ function stopHeartbeat(timer) {
203
226
  if (timer) clearInterval(timer);
204
227
  }
205
228
 
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
229
  /**
220
230
  * Release the lease — delete the lock ONLY when it is ours by `token`, so a
221
231
  * foreign or already-reclaimed lock is never removed out from under its owner
@@ -246,7 +256,6 @@ module.exports = {
246
256
  stamp,
247
257
  startHeartbeat,
248
258
  stopHeartbeat,
249
- updateWatchers,
250
259
  release,
251
260
  owns,
252
261
  };