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
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const {
6
+ DEFAULT_MANIFEST_DIR,
7
+ defaultGetProcessIdentity,
8
+ isValidManifest,
9
+ readProcessManifest,
10
+ } = require('../../scripts/process-tree');
11
+
12
+ function nonEmptyString(value) {
13
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
14
+ }
15
+
16
+ function positivePid(value) {
17
+ return Number.isInteger(value) && value > 0 ? value : null;
18
+ }
19
+
20
+ function verifiedManifestForClaim(claim, manifest) {
21
+ const sessionId = nonEmptyString(claim?.session_id);
22
+ const worktreeId = nonEmptyString(claim?.worktree_id);
23
+ if (!nonEmptyString(claim?.id) || !nonEmptyString(claim?.issue_id) || !sessionId || !worktreeId) return null;
24
+ if (claim.state != null && claim.state !== 'active') return null;
25
+ if (!isValidManifest(manifest) || manifest.token !== sessionId) return null;
26
+
27
+ const owner = manifest.owner;
28
+ const pid = positivePid(owner?.pid);
29
+ const identity = nonEmptyString(owner?.identity);
30
+ const startedAt = nonEmptyString(owner?.startedAt);
31
+ if (!pid || !identity || identity.startsWith('unverified:') || !startedAt || !Array.isArray(manifest.children)) {
32
+ return null;
33
+ }
34
+
35
+ return { sessionId, worktreeId, pid, identity, startedAt };
36
+ }
37
+
38
+ function defaultIsProcessAlive(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (error) {
43
+ return error?.code === 'EPERM';
44
+ }
45
+ }
46
+
47
+ function createManifestReader(options = {}) {
48
+ const fsApi = options.fsApi || fs;
49
+ const processApi = options.processApi || process;
50
+ const manifestDir = options.manifestDir || DEFAULT_MANIFEST_DIR;
51
+ return async (sessionId) => {
52
+ let entries;
53
+ try {
54
+ entries = fsApi.readdirSync(manifestDir, { withFileTypes: true });
55
+ } catch {
56
+ return null;
57
+ }
58
+ const matches = [];
59
+ for (const entry of entries) {
60
+ const name = typeof entry === 'string' ? entry : entry?.name;
61
+ if (!name || !name.endsWith('.json')) continue;
62
+ if (typeof entry !== 'string' && entry.isFile && !entry.isFile()) continue;
63
+ const manifest = readProcessManifest(path.join(manifestDir, name), fsApi, processApi);
64
+ if (manifest?.token === sessionId) matches.push(manifest);
65
+ }
66
+ return matches.length === 1 ? matches[0] : null;
67
+ };
68
+ }
69
+
70
+ function createWorktreeInspector(driver, fsApi = fs) {
71
+ return async (worktreeId, claim) => {
72
+ let rows;
73
+ try {
74
+ rows = await driver.listWorktrees({});
75
+ } catch {
76
+ return null;
77
+ }
78
+ const linked = (Array.isArray(rows) ? rows : []).filter(row => row
79
+ && row.issue_id === claim.issue_id
80
+ && row.state === 'active'
81
+ && (row.id === worktreeId || path.basename(row.path || '') === worktreeId));
82
+ if (linked.length !== 1 || !nonEmptyString(linked[0].path)) return null;
83
+ try {
84
+ if (typeof fsApi.lstatSync === 'function') {
85
+ fsApi.lstatSync(linked[0].path);
86
+ return { exists: true, row: linked[0] };
87
+ }
88
+ return { exists: fsApi.existsSync(linked[0].path), row: linked[0] };
89
+ } catch (error) {
90
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
91
+ return { exists: false, row: linked[0] };
92
+ }
93
+ return null;
94
+ }
95
+ };
96
+ }
97
+
98
+ function createMissingPathVerifier(fsApi = fs) {
99
+ return (worktreePath) => {
100
+ try {
101
+ if (typeof fsApi.lstatSync === 'function') fsApi.lstatSync(worktreePath);
102
+ else if (fsApi.existsSync(worktreePath)) return false;
103
+ else return true;
104
+ return false;
105
+ } catch (error) {
106
+ return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
107
+ }
108
+ };
109
+ }
110
+
111
+ async function reconcileClaims(options = {}) {
112
+ const claims = Array.isArray(options.claims) ? options.claims : [];
113
+ const readManifest = options.readManifest;
114
+ const worktreeExists = options.worktreeExists;
115
+ const isProcessAlive = options.isProcessAlive;
116
+ const getProcessIdentity = options.getProcessIdentity;
117
+ const releaseClaim = options.releaseClaim;
118
+ const released = [];
119
+ const attempted = new Set();
120
+
121
+ if (![readManifest, worktreeExists, isProcessAlive, getProcessIdentity, releaseClaim]
122
+ .every(dependency => typeof dependency === 'function')) {
123
+ return { examined: claims.length, released };
124
+ }
125
+
126
+ for (const claim of claims) {
127
+ if (!nonEmptyString(claim?.id) || attempted.has(claim.id)) continue;
128
+
129
+ let manifest;
130
+ try {
131
+ manifest = await readManifest(claim.session_id, claim);
132
+ } catch {
133
+ continue;
134
+ }
135
+ const verified = verifiedManifestForClaim(claim, manifest);
136
+ if (!verified) continue;
137
+
138
+ let worktreeInspection;
139
+ try {
140
+ worktreeInspection = await worktreeExists(verified.worktreeId, claim);
141
+ } catch {
142
+ continue;
143
+ }
144
+ const worktreeMissing = worktreeInspection === false
145
+ || worktreeInspection?.exists === false;
146
+ if (!worktreeMissing) continue;
147
+
148
+ let ownerAlive;
149
+ try {
150
+ ownerAlive = await isProcessAlive(verified.pid);
151
+ } catch {
152
+ continue;
153
+ }
154
+ if (ownerAlive !== false) {
155
+ if (ownerAlive !== true) continue;
156
+ let currentIdentity;
157
+ try {
158
+ currentIdentity = await getProcessIdentity(verified.pid);
159
+ } catch {
160
+ continue;
161
+ }
162
+ // A matching owner is live. A mismatch is PID reuse or otherwise
163
+ // unverifiable; neither authorizes releasing its claim.
164
+ if (currentIdentity !== verified.identity) continue;
165
+ continue;
166
+ }
167
+
168
+ attempted.add(claim.id);
169
+ const evidence = Object.freeze({
170
+ claim_id: claim.id,
171
+ issue_id: claim.issue_id,
172
+ session_id: verified.sessionId,
173
+ worktree_id: verified.worktreeId,
174
+ manifest_version: manifest.version,
175
+ manifest_token: manifest.token,
176
+ owner_pid: verified.pid,
177
+ owner_identity: verified.identity,
178
+ owner_started_at: verified.startedAt,
179
+ run_state: 'dead',
180
+ worktree_state: 'missing',
181
+ });
182
+
183
+ try {
184
+ if (await releaseClaim(claim, evidence, worktreeInspection)) released.push(claim.id);
185
+ } catch {
186
+ // A failed release is not evidence that authority changed.
187
+ }
188
+ }
189
+
190
+ return { examined: claims.length, released };
191
+ }
192
+
193
+ async function reconcileKernelClaims(options = {}) {
194
+ const driver = options.driver;
195
+ if (!driver || typeof driver.listWorktrees !== 'function'
196
+ || typeof driver.releaseExactClaimIfWorktreeMissing !== 'function') {
197
+ return { examined: 0, released: [] };
198
+ }
199
+
200
+ let claims;
201
+ try {
202
+ if (typeof driver.listActiveClaims === 'function') {
203
+ claims = await driver.listActiveClaims(options.context || {}, options.config || {});
204
+ } else {
205
+ const response = await driver.issueOperation('claims', [], options.context || {}, options.config || {});
206
+ claims = response?.data?.claims;
207
+ }
208
+ } catch {
209
+ return { examined: 0, released: [] };
210
+ }
211
+
212
+ return reconcileClaims({
213
+ claims,
214
+ readManifest: options.readManifest || createManifestReader(options),
215
+ worktreeExists: options.worktreeExists || createWorktreeInspector(driver, options.fsApi || fs),
216
+ isProcessAlive: options.isProcessAlive || defaultIsProcessAlive,
217
+ getProcessIdentity: options.getProcessIdentity || (pid => defaultGetProcessIdentity(pid, options)),
218
+ releaseClaim: async (claim, evidence, inspection) => {
219
+ const released = await driver.releaseExactClaimIfWorktreeMissing(
220
+ claim,
221
+ inspection?.row,
222
+ createMissingPathVerifier(options.fsApi || fs),
223
+ evidence,
224
+ options.config || {},
225
+ );
226
+ if (released && typeof options.onEvidence === 'function') options.onEvidence(evidence);
227
+ return released;
228
+ },
229
+ });
230
+ }
231
+
232
+ module.exports = {
233
+ createManifestReader,
234
+ createWorktreeInspector,
235
+ reconcileClaims,
236
+ reconcileKernelClaims,
237
+ verifiedManifestForClaim,
238
+ };
@@ -113,7 +113,18 @@ async function buildMigratedKernelIssueDeps(options = {}) {
113
113
  databasePath: deps.kernelDatabasePath,
114
114
  driver: deps.kernelDriver,
115
115
  });
116
- await ensureKernelMigrated(broker);
116
+ const close = async () => {
117
+ if (typeof deps.kernelDriver.close === 'function') {
118
+ await deps.kernelDriver.close();
119
+ }
120
+ };
121
+ broker.close = close;
122
+ try {
123
+ await ensureKernelMigrated(broker);
124
+ } catch (error) {
125
+ try { await close(); } catch { /* preserve the migration failure */ }
126
+ throw error;
127
+ }
117
128
  return {
118
129
  useKernelBroker: true,
119
130
  kernelBroker: broker,
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Close-on-merge linkage (kernel issue 18f1988e).
5
+ *
6
+ * The branch->issue linkage backbone shipped (kernel_worktrees rows written by
7
+ * `forge worktree create` and the auto-file rail), but nothing ever consumed it:
8
+ * a merged PR closed no kernel issue, so finished work stayed open forever. A
9
+ * hygiene sweep on 2026-07-25 closed 104 such issues, 61 of them auto-stubs
10
+ * minted on branch push whose merged PR closed nothing.
11
+ *
12
+ * This module is the consumer. Given a branch whose PR is known to be MERGED, it
13
+ * comments the merge evidence onto the linked kernel issue and closes it.
14
+ *
15
+ * Safety properties (each covered by test/kernel/close-on-merge.test.js):
16
+ * - LINKED ONLY. The issue comes from the kernel_worktrees linkage registry via
17
+ * resolveActiveIssueId — the same authoritative resolver `forge ship` stage
18
+ * state uses. An issue is never guessed from the branch/PR title, so a merge
19
+ * can only ever close the issue the branch was actually bound to.
20
+ * - IDEMPOTENT. The issue's status is read back first; a terminal issue
21
+ * (`done` / `cancelled` — the kernel's real terminal vocabulary) is a pure
22
+ * no-op that writes neither a comment nor a close. Running clean twice
23
+ * closes once.
24
+ * - NEVER CLOSES BLIND. An unreadable status is a skip, not an assumption.
25
+ * - BEST EFFORT. Every failure path — no kernel, a throwing driver, a rejected
26
+ * mutation — resolves to `{ closed: false, reason }`. Nothing throws, so
27
+ * `forge clean` still removes worktrees when tracking is broken.
28
+ *
29
+ * Writes go through the supported `runIssueOperation` seam (lib/forge-issues.js),
30
+ * the same path `forge inbox ack` uses, so kernel events/provenance are recorded
31
+ * exactly as a hand-run `forge comment` / `forge close` would be.
32
+ *
33
+ * @module kernel/close-on-merge
34
+ */
35
+
36
+ const { resolveActiveIssueId } = require('../workflow/enforce-stage');
37
+ const { isWorkableStatus } = require('./readiness-model');
38
+
39
+ /**
40
+ * Render the merge-evidence comment posted onto the issue before it is closed.
41
+ * Stays honest when the PR could not be resolved: `forge clean` also detects a
42
+ * merge by git patch-equivalence, with no GitHub data to cite.
43
+ *
44
+ * @param {object} params
45
+ * @param {string} params.branch - Merged branch name
46
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [params.pr]
47
+ * @returns {string} Comment body
48
+ */
49
+ function buildMergeEvidence({ branch, pr }) {
50
+ const lines = ['Closed automatically: the branch backing this issue was merged.', '', `branch: ${branch}`];
51
+ if (pr && pr.number) {
52
+ lines.push(`pull request: #${pr.number}${pr.title ? ` — ${pr.title}` : ''}`);
53
+ if (pr.mergeCommitOid) lines.push(`merge commit: ${pr.mergeCommitOid}`);
54
+ } else {
55
+ lines.push('pull request: no pull request resolved (merge detected from git history)');
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ /**
61
+ * Render the `--reason` recorded on the close event.
62
+ *
63
+ * @param {object} params
64
+ * @param {string} params.branch
65
+ * @param {{number: number}|null} [params.pr]
66
+ * @returns {string}
67
+ */
68
+ function buildCloseReason({ branch, pr }) {
69
+ return pr && pr.number ? `merged in PR #${pr.number}` : `branch ${branch} merged`;
70
+ }
71
+
72
+ /**
73
+ * Read an issue's current status through the supported read path.
74
+ *
75
+ * @param {Function} runIssueOperation
76
+ * @param {string} issueId
77
+ * @param {string} projectRoot
78
+ * @returns {Promise<string|null>} the status, or null when it could not be read.
79
+ */
80
+ async function readIssueStatus(runIssueOperation, issueId, projectRoot) {
81
+ const result = await runIssueOperation('show', [issueId, '--json'], projectRoot);
82
+ if (!result || result.ok !== true || !result.data) return null;
83
+ const status = result.data.status;
84
+ return typeof status === 'string' && status ? status : null;
85
+ }
86
+
87
+ /** @returns {boolean} whether a mutation envelope reports success. */
88
+ function mutationSucceeded(result) {
89
+ return Boolean(result && (result.ok === true || result.success === true));
90
+ }
91
+
92
+ /**
93
+ * Comment the merge evidence onto the linked kernel issue and close it.
94
+ * See the module doc for the full contract. NEVER throws.
95
+ *
96
+ * @param {object} options
97
+ * @param {string} options.branch - The merged branch.
98
+ * @param {string} [options.projectRoot] - Repo root threaded to the issue runner.
99
+ * @param {{number: number, title?: string, mergeCommitOid?: string}|null} [options.pr]
100
+ * Merge evidence from GitHub, when available.
101
+ * @param {object} options.driver - Kernel driver exposing the worktree linkage registry.
102
+ * @param {Function} options.runIssueOperation - (operation, args, projectRoot) => envelope.
103
+ * @param {Function} [options.resolveIssueId] - Override the branch->issue resolver (tests).
104
+ * @returns {Promise<{closed: boolean, issueId?: string|null, reason?: string,
105
+ * commented?: boolean, status?: string, error?: string}>}
106
+ */
107
+ async function closeLinkedIssueOnMerge(options = {}) {
108
+ const { branch, projectRoot, pr = null, driver, runIssueOperation } = options;
109
+ try {
110
+ if (!branch || typeof branch !== 'string') return { closed: false, reason: 'no-branch' };
111
+ if (!driver || typeof runIssueOperation !== 'function') {
112
+ return { closed: false, reason: 'unavailable' };
113
+ }
114
+
115
+ // Linked only — never a title/heuristic match.
116
+ const resolveIssueId = options.resolveIssueId || resolveActiveIssueId;
117
+ const issueId = await resolveIssueId(driver, branch);
118
+ if (!issueId) return { closed: false, reason: 'not-linked' };
119
+
120
+ // Idempotency + never-close-blind: read the current status first.
121
+ const status = await readIssueStatus(runIssueOperation, issueId, projectRoot);
122
+ if (!status) return { closed: false, reason: 'read-failed', issueId };
123
+ if (!isWorkableStatus(status)) {
124
+ return { closed: false, reason: 'already-closed', issueId, status };
125
+ }
126
+
127
+ // Evidence first, so it survives even if the close is rejected.
128
+ const commentResult = await runIssueOperation(
129
+ 'comment',
130
+ [issueId, buildMergeEvidence({ branch, pr })],
131
+ projectRoot,
132
+ );
133
+ const commented = mutationSucceeded(commentResult);
134
+
135
+ const closeResult = await runIssueOperation(
136
+ 'close',
137
+ [issueId, '--reason', buildCloseReason({ branch, pr })],
138
+ projectRoot,
139
+ );
140
+ if (!mutationSucceeded(closeResult)) {
141
+ return { closed: false, reason: 'close-failed', issueId, commented };
142
+ }
143
+ return { closed: true, issueId, commented };
144
+ } catch (error) {
145
+ // Best-effort: cleanup must never break on tracking.
146
+ return { closed: false, reason: 'error', error: error && error.message };
147
+ }
148
+ }
149
+
150
+ module.exports = {
151
+ closeLinkedIssueOnMerge,
152
+ buildMergeEvidence,
153
+ buildCloseReason,
154
+ };
@@ -284,7 +284,7 @@ function driveLetterOf(absPath) {
284
284
 
285
285
  // Bounded exec options for the Windows drive probes. The timeout is the BLOCKER-1
286
286
  // fix: execFileSync's try/catch only catches throws, never hangs, so an
287
- // unresponsive `net use` / PowerShell (e.g. a wedged network redirector) would
287
+ // an unresponsive `net use` probe (e.g. a wedged network redirector) would
288
288
  // hang the whole process indefinitely. With a timeout, a hang becomes a throw
289
289
  // that the gatherSignals catch turns into driveType='unknown' → warn (never
290
290
  // refuse), symmetric with the G1 fail-safe. killSignal forces the child dead.
@@ -310,18 +310,21 @@ function parseNetUseDriveType(stdout, driveLetter) {
310
310
  return null;
311
311
  }
312
312
 
313
- // PURE (M5): a non-empty PowerShell DisplayRoot means the PSDrive is backed by a
314
- // network/remote root. Returns 'network' when non-blank, else null.
315
- function parseDisplayRoot(stdout) {
316
- return String(stdout || '').trim() ? 'network' : null;
317
- }
318
-
319
313
  // Windows: detect whether a drive letter is a mapped network drive.
320
- // FAIL-SAFE FALLBACK (locked decision G1, option a): try `net use`, then a
321
- // PowerShell DisplayRoot fallback; on ANY probe failure (including a timeout-
322
- // induced throw, per BLOCKER 1) the caller treats the result as 'unknown'.
323
- // Probes use arg ARRAYS (no shell) so they remain injection-free.
314
+ // The conventional local C: drive is fixed without a subprocess. `net use` is
315
+ // authoritative for other mapped drive letters, while a no-match remains
316
+ // unknown because it does not prove that an SMB/cloud redirector is absent. The unknown
317
+ // result is fail-open (warn), while UNC and cloud path signals still refuse.
318
+ // Do not launch a second shell process for every kernel child. Under a heavily
319
+ // sharded suite those redundant fallback probes contend for host resources and
320
+ // can leave a shard blocked indefinitely.
321
+ // A probe failure still propagates to the gatherer, which maps it to the
322
+ // fail-safe `unknown`/warn classification.
323
+ // The probe uses an arg ARRAY (no shell) so it remains injection-free.
324
324
  function defaultProbeDriveType(driveLetter, deps = {}) {
325
+ const normalizedDriveLetter = String(driveLetter || '').trim().toUpperCase();
326
+ if (normalizedDriveLetter === 'C:') return 'fixed';
327
+
325
328
  const exec = deps.execFileSync || execFileSync;
326
329
 
327
330
  // Primary: `net use` lists mapped drive letters (and their remote paths).
@@ -330,17 +333,9 @@ function defaultProbeDriveType(driveLetter, deps = {}) {
330
333
  return 'network';
331
334
  }
332
335
 
333
- // Secondary: PowerShell DisplayRoot — non-empty for network-backed PSDrives.
334
- const psLetter = String(driveLetter).replace(':', '');
335
- const psCmd =
336
- `(Get-PSDrive -Name '${psLetter}' -ErrorAction SilentlyContinue).DisplayRoot`;
337
- const psOut = exec('powershell', ['-NoProfile', '-Command', psCmd], WIN_PROBE_EXEC_OPTIONS);
338
- if (parseDisplayRoot(psOut) === 'network') {
339
- return 'network';
340
- }
341
-
342
- // Letter exists, no network backing detected → fixed.
343
- return 'fixed';
336
+ // `net use` completed successfully without listing this non-system letter.
337
+ // A no-match is not evidence that an SMB/cloud redirector is absent.
338
+ return 'unknown';
344
339
  }
345
340
 
346
341
  // Linux: read /proc/mounts, return fs type of the longest mountpoint prefixing absPath.
@@ -443,27 +438,49 @@ function isUnsafeFsOverrideActive(env = {}) {
443
438
  return value === '1' || value === 'true';
444
439
  }
445
440
 
441
+ // The gate runs on every broker instance, and one command builds several
442
+ // (`forge status` and `forge prime` each build four), so an un-memoized advisory
443
+ // printed four times per command. The advisory describes the process's database
444
+ // path, not the broker, so it is emitted once per (tier, class, path) per
445
+ // process. Keyed rather than a single flag so a second path — or a path that
446
+ // reclassifies — is still reported. Warnings only: `refuse` still throws on
447
+ // every call, so deduping can never turn a refusal into a pass.
448
+ const warnedFilesystemKeys = new Set();
449
+
450
+ function resetFilesystemWarningMemo() {
451
+ warnedFilesystemKeys.clear();
452
+ }
453
+
446
454
  function assertFilesystemSafeForKernel(dbPath, deps = {}) {
447
455
  const env = deps.env || process.env;
456
+ // console.warn writes to stderr, which is the contract here: `--json`
457
+ // consumers parse stdout, so an advisory on stdout would corrupt the envelope.
448
458
  const warn = deps.warn || (msg => console.warn(msg));
449
459
  const classify = deps.classifyFilesystem || classifyFilesystem;
450
460
 
451
461
  const classification = classify(dbPath, deps);
452
462
  const remediation = REMEDIATION[classification.remediationKey] || REMEDIATION.unknown;
453
463
 
464
+ const warnOnce = message => {
465
+ const key = `${classification.riskTier}|${classification.class}|${dbPath}`;
466
+ if (warnedFilesystemKeys.has(key)) return;
467
+ warnedFilesystemKeys.add(key);
468
+ warn(message);
469
+ };
470
+
454
471
  if (classification.riskTier === 'safe') {
455
472
  return classification;
456
473
  }
457
474
 
458
475
  if (classification.riskTier === 'warn') {
459
- warn(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
476
+ warnOnce(`forge kernel filesystem warning (${classification.class}):\n${remediation}`);
460
477
  return classification;
461
478
  }
462
479
 
463
480
  // refuse
464
481
  const overrideActive = isUnsafeFsOverrideActive(env);
465
482
  if (overrideActive) {
466
- warn(
483
+ warnOnce(
467
484
  `forge kernel filesystem REFUSE downgraded by FORGE_KERNEL_ALLOW_UNSAFE_FS ` +
468
485
  `(${classification.class}) — override active:\n${remediation}`,
469
486
  );
@@ -487,9 +504,9 @@ module.exports = {
487
504
  gatherSignals,
488
505
  classifyFilesystem,
489
506
  assertFilesystemSafeForKernel,
507
+ resetFilesystemWarningMemo,
490
508
  isUnsafeFsOverrideActive,
491
509
  defaultProbeDriveType,
492
510
  parseNetUseDriveType,
493
- parseDisplayRoot,
494
511
  REMEDIATION,
495
512
  };
@@ -78,12 +78,17 @@ function isValidExpiresAt(value) {
78
78
  return new Date(timestamp).toISOString() === value;
79
79
  }
80
80
 
81
- // A lease with no expires_at never expires. Timestamps are validated UTC ISO-8601
82
- // with a trailing 'Z' (see isValidExpiresAt), so lexicographic comparison equals
83
- // chronological comparison.
81
+ // A lease with no expires_at never expires. Normalize both instants before comparing:
82
+ // callers supply `now`, so it may be non-canonical or unusable even though stored
83
+ // expires_at is canonical. Without a usable clock expiry is not proven; conservatively
84
+ // keep the lease live rather than allowing a false reclaim or ready projection.
84
85
  function isLeaseExpired(claim, now) {
85
86
  if (!claim?.expires_at) return false;
86
- return claim.expires_at <= now;
87
+ if (typeof now !== 'string') return false;
88
+ const expiresAt = Date.parse(claim.expires_at);
89
+ const clock = Date.parse(now);
90
+ if (!Number.isFinite(expiresAt) || !Number.isFinite(clock)) return false;
91
+ return expiresAt <= clock;
87
92
  }
88
93
 
89
94
  function buildClaimRow(event, now) {