forge-workflow 0.1.0-beta.4 → 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 (119) 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 +20 -0
  5. package/bin/forge.js +16 -374
  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 +8 -5
  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 +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -7,12 +7,11 @@
7
7
  * debounce guard. This module is the thin, side-effecting dispatcher over them:
8
8
  * it gathers the two state sets (GitHub via `gh`, kernel via the broker), runs the
9
9
  * actions `reconcile()` emits (spawn/stop/reap watchers, upsert/retire kernel_pr
10
- * rows), owns the SINGLETON DAEMON lease lifecycle, and provides the per-command
11
- * `fireAndForget()` trigger wired into `bin/forge.js`.
10
+ * rows), owns the SINGLETON DAEMON lease lifecycle, and provides the approved-seam
11
+ * `fireAndForget()` trigger used by session-start, push, and ship.
12
12
  *
13
13
  * The NON-BLOCKING / ERROR-SWALLOWING contract is paramount: `fireAndForget()`
14
- * MUST never throw and never affect the command that triggered it (it is called
15
- * from a `finally` in the dispatch chokepoint). Every spawn is modeled on
14
+ * MUST never throw and never affect the command that triggered it. Every spawn is modeled on
16
15
  * `watch-lifecycle.startPrWatcherDetached` (detached, `stdio:'ignore'`,
17
16
  * `windowsHide:true`, `.unref()`, no-op `'error'` listener) so a failed launch
18
17
  * degrades to "not started" rather than crashing.
@@ -39,10 +38,69 @@ const shepherdLease = require('./shepherd-lease');
39
38
  const journal = require('./journal');
40
39
  const { reconcile: defaultReconcile } = require('./reconcile');
41
40
  const { tick: defaultTick } = require('./reconcile-tick');
42
- const { startPrWatcherDetached, defaultResolveSlug, forgeBin } = require('./watch-lifecycle');
41
+ const { startPrWatcherDetached, forgeBin } = require('./watch-lifecycle');
43
42
  const brokerMod = require('../kernel/broker');
44
43
 
45
44
  const { STALE_MS } = shepherdLease;
45
+ const CANONICAL_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
46
+ const GH_COMMAND_TIMEOUT_MS = 30_000;
47
+ const LINKAGE_FIELDS = ['issue_id', 'worktree_id', 'journal_ptr'];
48
+
49
+ function normalizeRepository(value) {
50
+ return typeof value === 'string' && CANONICAL_REPOSITORY.test(value.trim())
51
+ ? value.trim().toLowerCase()
52
+ : null;
53
+ }
54
+
55
+ /** Resolve the GitHub identity used by merge binding, never a bare repo basename. */
56
+ function resolveCanonicalRepository(runGh) {
57
+ try {
58
+ const raw = runGh(['repo', 'view', '--json', 'owner,name']);
59
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
60
+ const owner = parsed && parsed.owner && parsed.owner.login;
61
+ const name = parsed && parsed.name;
62
+ if (typeof owner !== 'string' || owner.trim() === '' || typeof name !== 'string' || name.trim() === '') return null;
63
+ return normalizeRepository(`${owner}/${name}`);
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Persist the daemon's latest lifecycle outcome beside the lease. This status
71
+ * file is deliberately separate from `shepherd.reconcile`: that sentinel's
72
+ * mtime is the cold-trigger debounce clock and diagnostics must never move it.
73
+ */
74
+ function writeDaemonDiagnostic(gitCommonDir, entry, opts = {}) {
75
+ try {
76
+ const file = path.join(gitCommonDir, 'forge', 'shepherd.status.json');
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ const now = opts.now || (() => Date.now());
79
+ const payload = {
80
+ ...entry,
81
+ pid: opts.pid ?? process.pid,
82
+ at: new Date(now()).toISOString(),
83
+ };
84
+ fs.writeFileSync(file, `${JSON.stringify(payload)}\n`);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Record through an injected test seam or the separate durable status file. */
92
+ function recordDaemonDiagnostic(opts, gitCommonDir, kind, detail) {
93
+ const entry = {
94
+ kind,
95
+ ...(detail ? { detail: (detail && detail.message) || String(detail) } : {}),
96
+ };
97
+ try {
98
+ if (typeof opts.recordDiagnostic === 'function') opts.recordDiagnostic(entry);
99
+ else writeDaemonDiagnostic(gitCommonDir, entry, { now: opts.now });
100
+ } catch {
101
+ /* diagnostics must never affect a command or daemon lifecycle */
102
+ }
103
+ }
46
104
 
47
105
  /** Normalize a lease watcher entry to the W-S4b `{pr, repo, pid, startedAt}` shape. */
48
106
  function normalizeWatcher(entry) {
@@ -65,10 +123,10 @@ function claimPath(dir) {
65
123
  * kill-time re-verification token — orphan reaping refuses to kill a pid unless
66
124
  * this marker still equals the watcher entry's `startedAt`.
67
125
  */
68
- function writeClaimMarker(projectRoot, repo, pr, startedAt) {
126
+ function writeClaimMarker(projectRoot, repo, pr, startedAt, gitCommonDir) {
69
127
  if (repo == null || pr == null || startedAt == null) return;
70
128
  try {
71
- const dir = journal.journalDir({ root: projectRoot, repo, pr });
129
+ const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
72
130
  fs.writeFileSync(claimPath(dir), String(startedAt));
73
131
  } catch {
74
132
  /* best-effort — a missing marker just means the pid is treated as unverifiable (never reaped) */
@@ -76,10 +134,10 @@ function writeClaimMarker(projectRoot, repo, pr, startedAt) {
76
134
  }
77
135
 
78
136
  /** Read the start-time marker for `(repo, pr)`, or null when absent/unreadable. */
79
- function readClaimMarker(projectRoot, repo, pr) {
137
+ function readClaimMarker(projectRoot, repo, pr, gitCommonDir) {
80
138
  if (repo == null || pr == null) return null;
81
139
  try {
82
- const dir = journal.journalDir({ root: projectRoot, repo, pr });
140
+ const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
83
141
  return fs.readFileSync(claimPath(dir), 'utf8').trim();
84
142
  } catch {
85
143
  return null;
@@ -91,10 +149,10 @@ function readClaimMarker(projectRoot, repo, pr) {
91
149
  * a future PID reuse can't match a STALE marker and get treated as the live watcher.
92
150
  * Best-effort; a missing marker is fine (an unverifiable pid is never reaped anyway).
93
151
  */
94
- function removeClaimMarker(projectRoot, repo, pr) {
152
+ function removeClaimMarker(projectRoot, repo, pr, gitCommonDir) {
95
153
  if (repo == null || pr == null) return;
96
154
  try {
97
- const dir = journal.journalDir({ root: projectRoot, repo, pr });
155
+ const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
98
156
  fs.rmSync(claimPath(dir), { force: true });
99
157
  } catch {
100
158
  /* best-effort marker cleanup */
@@ -110,14 +168,17 @@ function removeClaimMarker(projectRoot, repo, pr) {
110
168
  */
111
169
  async function gatherDesired(gitCommonDir, opts = {}) {
112
170
  const runGh = opts.runGh || ((args) => require('node:child_process').execFileSync('gh', args, {
113
- encoding: 'utf8', timeout: 30000, windowsHide: true,
171
+ cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
114
172
  }));
115
173
  // The broker is a live kernel handle from createLocalBroker (listOpenPrs/upsertPr/
116
174
  // retirePr are INSTANCE methods) — the daemon threads one in. Never fall back to the
117
175
  // broker MODULE namespace: those methods don't exist there and every call would
118
176
  // silently no-op behind the catch (this exact bug shipped once — keep it gone).
119
177
  const broker = opts.broker || null;
120
- const repo = opts.repo || defaultResolveSlug({ cwd: opts.projectRoot || process.cwd() });
178
+ // Explicit repo injection remains a test/programmatic seam. Production resolves the
179
+ // owner/name pair from GitHub; a bare basename is never inferred from a failed lookup.
180
+ const suppliedRepo = typeof opts.repo === 'string' && opts.repo.trim() ? opts.repo.trim() : null;
181
+ const repo = suppliedRepo || resolveCanonicalRepository(runGh);
121
182
 
122
183
  let ghPrs = [];
123
184
  // `listingOk` distinguishes "GitHub says zero open PRs" from "the gh call failed"
@@ -135,22 +196,85 @@ async function gatherDesired(gitCommonDir, opts = {}) {
135
196
  listingOk = false;
136
197
  }
137
198
 
199
+ if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
200
+
138
201
  let prRows = [];
139
202
  try {
140
203
  if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
141
204
  } catch {
142
205
  /* kernel unavailable → treat as no linkage; the GitHub-driven desired set still stands */
143
206
  }
144
- // Key by (repo, number) — matches reconcile.js `keyOf` — so a repo rename that
145
- // leaves a same-number row under one git_common_dir can't attach the wrong repo's
146
- // linkage to the desired PR.
147
- const keyOf = (r, n) => `${r} ${n}`;
148
- const rowByKey = new Map((Array.isArray(prRows) ? prRows : []).map((r) => [keyOf(r.repo, r.number), r]));
207
+ // Key by canonical (repo, number). A single bare-name row is accepted
208
+ // only as a one-way compatibility bridge; it is rewritten under the canonical key
209
+ // and retired by the same reconcile pass. The common-dir natural key plus an exact
210
+ // basename/number match preserves links across force-pushes; cross-repository,
211
+ // ambiguous, and other malformed rows never bind.
212
+ const canonicalRepo = normalizeRepository(repo);
213
+ const legacyRepo = canonicalRepo ? canonicalRepo.slice(canonicalRepo.lastIndexOf('/') + 1) : null;
214
+ const validPrNumber = (value) => (Number.isSafeInteger(value) && value > 0)
215
+ || (typeof value === 'string' && /^[1-9][0-9]*$/.test(value));
216
+ const rows = Array.isArray(prRows) ? prRows : [];
217
+ if (canonicalRepo) {
218
+ const counts = new Map();
219
+ const conflicting = rows.some((row) => {
220
+ if (!row || !validPrNumber(row.number)) return true;
221
+ const rowRepo = normalizeRepository(row.repo);
222
+ const canonical = rowRepo === canonicalRepo;
223
+ const legacy = typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo;
224
+ if (!canonical && !legacy) return true;
225
+ const number = String(row.number);
226
+ const count = counts.get(number) || { canonical: 0, legacy: 0 };
227
+ if (canonical) count.canonical += 1;
228
+ else count.legacy += 1;
229
+ counts.set(number, count);
230
+ return count.canonical > 1 || count.legacy > 1;
231
+ });
232
+ if (conflicting) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
233
+ }
234
+ const exactRows = new Map();
235
+ const legacyRows = new Map();
236
+ const add = (map, number, row) => {
237
+ if (!validPrNumber(number)) return;
238
+ const list = map.get(String(number)) || [];
239
+ list.push(row);
240
+ map.set(String(number), list);
241
+ };
242
+ for (const row of rows) {
243
+ if (canonicalRepo && normalizeRepository(row && row.repo) === canonicalRepo) add(exactRows, row.number, row);
244
+ else if (legacyRepo && row && typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo) add(legacyRows, row.number, row);
245
+ else if (!canonicalRepo && row && row.repo === repo) add(exactRows, row.number, row);
246
+ }
247
+ const coalesceLinkage = (canonical, legacy) => {
248
+ if (!canonical || !legacy) return canonical || legacy || null;
249
+ const merged = { ...canonical };
250
+ for (const field of LINKAGE_FIELDS) {
251
+ const canonicalValue = canonical[field] ?? null;
252
+ const legacyValue = legacy[field] ?? null;
253
+ if (canonicalValue != null && legacyValue != null && canonicalValue !== legacyValue) return null;
254
+ if (canonicalValue == null && legacyValue != null) merged[field] = legacyValue;
255
+ }
256
+ return merged;
257
+ };
258
+ const mergedRows = new Map();
259
+ for (const number of new Set([...exactRows.keys(), ...legacyRows.keys()])) {
260
+ const exact = exactRows.get(number) || [];
261
+ const legacy = legacyRows.get(number) || [];
262
+ const merged = coalesceLinkage(exact[0], legacy[0]);
263
+ if (exact.length > 0 && legacy.length > 0 && !merged) {
264
+ return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
265
+ }
266
+ if (merged) mergedRows.set(number, merged);
267
+ }
268
+ const rowForPr = (p) => {
269
+ if (canonicalRepo) return mergedRows.get(String(p.number)) || null;
270
+ const exact = exactRows.get(String(p.number)) || [];
271
+ return exact.length === 1 ? exact[0] : null;
272
+ };
149
273
 
150
274
  const openPrs = ghPrs.map((p) => {
151
- const row = rowByKey.get(keyOf(repo, p.number));
275
+ const row = rowForPr(p);
152
276
  return {
153
- repo,
277
+ repo: canonicalRepo || repo,
154
278
  number: p.number,
155
279
  branch: p.headRefName ?? null,
156
280
  headSha: p.headRefOid ?? null,
@@ -169,7 +293,8 @@ async function gatherDesired(gitCommonDir, opts = {}) {
169
293
  async function gatherObserved(gitCommonDir, lock, opts = {}) {
170
294
  const broker = opts.broker || null; // live kernel handle threaded by the daemon; never the module namespace
171
295
  const isAlive = opts.isAlive || shepherdLease.pidAlive;
172
- const readClaim = opts.readClaim || ((repo, pr) => readClaimMarker(opts.projectRoot, repo, pr));
296
+ const readClaim = opts.readClaim
297
+ || ((repo, pr) => readClaimMarker(opts.projectRoot, repo, pr, gitCommonDir));
173
298
  const now = (opts.now || (() => Date.now()))();
174
299
 
175
300
  let prRows = [];
@@ -206,7 +331,8 @@ function verifiedKill(entry, ctx) {
206
331
  if (!entry || entry.pid == null || entry.startedAt == null) return false;
207
332
  const isAlive = ctx.isAlive || shepherdLease.pidAlive;
208
333
  if (!isAlive(entry.pid)) return false;
209
- const readClaim = ctx.readClaim || ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr));
334
+ const readClaim = ctx.readClaim
335
+ || ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir));
210
336
  const claim = readClaim(entry);
211
337
  if (claim == null || String(claim) !== String(entry.startedAt)) return false;
212
338
  try {
@@ -228,7 +354,9 @@ const ACTION_HANDLERS = {
228
354
  startWatcher(action, s) {
229
355
  const startedAt = new Date(s.now()).toISOString();
230
356
  const repo = action.pr.repo ?? s.repo ?? null;
231
- const res = s.spawnWatcher({ prNumber: action.pr.number, cwd: s.projectRoot });
357
+ const res = s.spawnWatcher({
358
+ prNumber: action.pr.number, cwd: s.projectRoot, gitCommonDir: s.gitCommonDir,
359
+ });
232
360
  const pid = res && res.pid != null ? res.pid : null;
233
361
  // A pid-less result means the watcher is already running (ship/push/adopt started it,
234
362
  // startPrWatcherDetached → {started:false, reason:'already-running'}) or the spawn
@@ -254,9 +382,12 @@ const ACTION_HANDLERS = {
254
382
  },
255
383
  async upsertPrRow(action, s) {
256
384
  try {
257
- if (s.broker) await s.broker.upsertPr(action.row);
385
+ if (!s.broker) return true;
386
+ const result = await s.broker.upsertPr(action.row);
387
+ return result !== false && !(result && result.ok === false);
258
388
  } catch {
259
389
  /* derived reconcile state — a failed upsert retries on the next converge */
390
+ return false;
260
391
  }
261
392
  },
262
393
  async retire(action, s) {
@@ -272,7 +403,8 @@ const ACTION_HANDLERS = {
272
403
  };
273
404
 
274
405
  /**
275
- * Dispatch a reconcile action set. Idempotent and order-free. Returns the updated
406
+ * Dispatch a reconcile action set in order. A failed kernel upsert stops the pass so
407
+ * dependent retire actions retry on the next converge. Returns the updated
276
408
  * watcher entry list (`{pr,repo,pid,startedAt}[]`) for the caller to publish via
277
409
  * `updateWatchers`. `ctx.watchers` seeds the current set (from observed state).
278
410
  */
@@ -280,8 +412,10 @@ async function execute(actions, ctx = {}) {
280
412
  const s = {
281
413
  broker: ctx.broker || null, // live kernel handle threaded by the daemon; never the module namespace
282
414
  spawnWatcher: ctx.spawnWatcher || startPrWatcherDetached,
283
- writeClaim: ctx.writeClaim || ((e) => writeClaimMarker(ctx.projectRoot, e.repo, e.pr, e.startedAt)),
284
- removeClaim: ctx.removeClaim || ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr)),
415
+ writeClaim: ctx.writeClaim
416
+ || ((e) => writeClaimMarker(ctx.projectRoot, e.repo, e.pr, e.startedAt, ctx.gitCommonDir)),
417
+ removeClaim: ctx.removeClaim
418
+ || ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir)),
285
419
  now: ctx.now || (() => Date.now()),
286
420
  repo: ctx.repo,
287
421
  projectRoot: ctx.projectRoot,
@@ -292,7 +426,7 @@ async function execute(actions, ctx = {}) {
292
426
 
293
427
  for (const action of (Array.isArray(actions) ? actions : [])) {
294
428
  const handler = ACTION_HANDLERS[action && action.type];
295
- if (handler) await handler(action, s);
429
+ if (handler && (await handler(action, s)) === false) break;
296
430
  }
297
431
  return s.watchers;
298
432
  }
@@ -367,6 +501,10 @@ async function runDaemon(projectRoot, opts = {}) {
367
501
  const release = opts.release || shepherdLease.release;
368
502
  const converge = opts.convergeOnce || convergeOnce;
369
503
  const now = opts.now || (() => Date.now());
504
+ // Tests that replace acquisition own the matching ownership seam as well.
505
+ // Production always verifies the real shared lock by exact pid+token.
506
+ const ownsLease = opts.ownsLease
507
+ || (opts.acquire ? (() => true) : shepherdLease.owns);
370
508
  // Injectable exit so a lifecycle test can assert the daemon actually exits
371
509
  // (finding 4). `opts.exit === false` keeps the process alive (legacy test mode).
372
510
  const exit = typeof opts.exit === 'function'
@@ -375,6 +513,7 @@ async function runDaemon(projectRoot, opts = {}) {
375
513
 
376
514
  const res = acquire(projectRoot, { gitCommonDir });
377
515
  if (!res.ok) {
516
+ exit(0);
378
517
  // A live, fresh foreign daemon owns this repo — exit immediately, spawn nothing.
379
518
  return { ok: false, reason: 'foreign-lease' };
380
519
  }
@@ -413,7 +552,10 @@ async function runDaemon(projectRoot, opts = {}) {
413
552
 
414
553
  if (opts.once) {
415
554
  const conv = await converge(projectRoot, convergeArgs);
416
- if (conv.desiredCount === 0) await retire();
555
+ if (conv.desiredCount === 0) {
556
+ recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
557
+ await retire();
558
+ }
417
559
  return { ok: true, token, ...conv };
418
560
  }
419
561
 
@@ -423,10 +565,30 @@ async function runDaemon(projectRoot, opts = {}) {
423
565
  let lastWatchers = []; // finding 2: thread the live watcher set across passes
424
566
  let timer = null;
425
567
 
568
+ const retireForLeaseLoss = async () => {
569
+ stopped = true;
570
+ if (timer) clearInterval(timer);
571
+ recordDaemonDiagnostic(opts, gitCommonDir, 'lease-lost');
572
+ await retire();
573
+ exit(0);
574
+ };
575
+
576
+ const stillOwnsLease = () => {
577
+ try {
578
+ return ownsLease(projectRoot, { gitCommonDir, token });
579
+ } catch {
580
+ return false;
581
+ }
582
+ };
583
+
426
584
  const runPass = async () => {
427
585
  // A tick that fires while the previous pass is still in flight (converge slower
428
586
  // than intervalMs) returns immediately, so passes never race on start/stop/reap.
429
587
  if (stopped || inFlight) return;
588
+ if (!stillOwnsLease()) {
589
+ await retireForLeaseLoss();
590
+ return;
591
+ }
430
592
  inFlight = true;
431
593
  try {
432
594
  // Thread the live watcher set + a fresh heartbeat stamp so gatherObserved
@@ -434,22 +596,26 @@ async function runDaemon(projectRoot, opts = {}) {
434
596
  // saw lease:null every tick and re-started a watcher for every PR forever.
435
597
  const passLock = { watchers: lastWatchers, heartbeatAt: new Date(now()).toISOString() };
436
598
  const conv = await converge(projectRoot, { ...convergeArgs, lock: passLock });
599
+ if (!stillOwnsLease()) {
600
+ await retireForLeaseLoss();
601
+ return;
602
+ }
437
603
  if (conv && Array.isArray(conv.watchers)) lastWatchers = conv.watchers;
438
604
  // Superseded: a newer daemon reclaimed our stale lease. Stop and exit — retire()
439
605
  // won't touch the foreign lock (release is token-guarded), so the new owner is
440
606
  // left intact; we just stop spawning/reaping behind it.
441
607
  if (conv && conv.leaseLost) {
442
- stopped = true;
443
- if (timer) clearInterval(timer);
444
- await retire();
445
- exit(0);
608
+ await retireForLeaseLoss();
446
609
  } else if (conv && conv.desiredCount === 0) {
447
610
  stopped = true;
448
611
  if (timer) clearInterval(timer);
612
+ recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
449
613
  await retire();
450
614
  exit(0);
451
615
  }
452
- } catch {
616
+ } catch (error) {
617
+ recordDaemonDiagnostic(opts, gitCommonDir, 'converge-failed', error);
618
+ if (!stillOwnsLease()) await retireForLeaseLoss();
453
619
  /* a bad converge pass never crashes the daemon — the next tick retries */
454
620
  } finally {
455
621
  inFlight = false;
@@ -478,9 +644,12 @@ async function runDaemon(projectRoot, opts = {}) {
478
644
  */
479
645
  function launchDaemon(ctx = {}) {
480
646
  const harness = ctx.harness || {};
647
+ const commonRoot = path.basename(ctx.gitCommonDir || '').toLowerCase() === '.git'
648
+ ? path.dirname(ctx.gitCommonDir)
649
+ : ctx.projectRoot;
481
650
  if (harness.hasBgShell && typeof harness.runBgShell === 'function') {
482
651
  try {
483
- harness.runBgShell([forgeBin(), 'shepherd', 'daemon']);
652
+ harness.runBgShell([process.execPath, forgeBin(), 'shepherd', 'daemon'], { cwd: commonRoot });
484
653
  return { launched: true, via: 'bg-shell' };
485
654
  } catch {
486
655
  /* fall through to the detached fail-safe */
@@ -491,12 +660,17 @@ function launchDaemon(ctx = {}) {
491
660
  const child = spawnFn(
492
661
  process.execPath,
493
662
  [forgeBin(), 'shepherd', 'daemon'],
494
- { cwd: ctx.projectRoot, detached: true, stdio: 'ignore', windowsHide: true },
663
+ { cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true },
495
664
  );
496
- if (child && typeof child.on === 'function') child.on('error', () => {});
665
+ if (child && typeof child.on === 'function') {
666
+ child.on('error', (error) => {
667
+ recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
668
+ });
669
+ }
497
670
  if (child && typeof child.unref === 'function') child.unref();
498
671
  return { launched: true, via: 'detached', pid: child && child.pid != null ? child.pid : null };
499
- } catch {
672
+ } catch (error) {
673
+ recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
500
674
  return { launched: false };
501
675
  }
502
676
  }
@@ -570,7 +744,7 @@ function emptyEnum(gitCommonDir) {
570
744
  }
571
745
 
572
746
  /**
573
- * The per-command / session-start trigger. Runs the `tick()` debounce; the hot
747
+ * The session-start / successful-push / successful-ship trigger. Runs the `tick()` debounce; the hot
574
748
  * path (a fresh daemon lease) short-circuits in-process with a single lock read
575
749
  * and no spawn. Only on the cold (G3) path does it ARBITRATE via the O_EXCL lease:
576
750
  * the acquire-winner launches the singleton daemon (which does the real
@@ -586,16 +760,24 @@ function emptyEnum(gitCommonDir) {
586
760
  */
587
761
  function fireAndForget(ctx = {}) {
588
762
  try {
763
+ const env = ctx.env || process.env;
589
764
  // Operator kill-switch (agent-agnostic): a set FORGE_SHEPHERD_DISABLE turns the
590
765
  // autonomous trigger fully inert — no lease, no enumeration, no daemon spawn.
591
- if (process.env.FORGE_SHEPHERD_DISABLE) return;
766
+ if (
767
+ env.FORGE_SHEPHERD_DISABLE
768
+ || env.NODE_ENV === 'test'
769
+ || env.BUN_ENV === 'test'
770
+ || env.CI
771
+ || env.GITHUB_ACTIONS
772
+ || env.GITLAB_CI
773
+ ) return;
592
774
  const projectRoot = ctx.projectRoot;
593
775
  if (!projectRoot) return;
594
776
  // A dry-run must have ZERO side effects, and the trigger must CREATE NOTHING in an
595
777
  // uninitialized / setup-or-init TARGET repo (no-lazy-create invariant, same as prime).
596
778
  // Both guards run BEFORE any git/lock touch so `setup --dry-run` / `init` stay
597
779
  // side-effect- AND output-clean (kernelInitialized is silent). Checked here, not the
598
- // caller, so every trigger site (dispatch, session-start) is covered uniformly.
780
+ // caller, so every approved trigger site is covered uniformly.
599
781
  if (ctx.dryRun) return;
600
782
  if (!(ctx.kernelInitialized || kernelInitialized)(projectRoot)) return;
601
783
  // Config kill-switch (same gate ship/push/adopt honor): a maintainer who ran
@@ -654,6 +836,7 @@ module.exports = {
654
836
  convergeOnce,
655
837
  runDaemon,
656
838
  launchDaemon,
839
+ writeDaemonDiagnostic,
657
840
  defaultBuildBroker,
658
841
  fireAndForget,
659
842
  };
@@ -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
+ };