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,773 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { randomUUID } = require('node:crypto');
7
+ const { spawnSync: defaultSpawnSync } = require('node:child_process');
8
+
9
+ const MANIFEST_ENV = 'FORGE_TEST_PROCESS_MANIFEST';
10
+ const TOKEN_ENV = 'FORGE_TEST_PROCESS_TOKEN';
11
+ const INSTANCE_ENV = 'FORGE_TEST_PROCESS_INSTANCE';
12
+ const SESSION_ENV = 'FORGE_SESSION_ID';
13
+ const MANIFEST_VERSION = 1;
14
+ const DEFAULT_MANIFEST_DIR = path.join(os.tmpdir(), 'forge-test-processes');
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function normalizePid(value) {
21
+ const pid = Number(value);
22
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
23
+ }
24
+
25
+ function nonEmptyString(value) {
26
+ return typeof value === 'string' && value.length > 0 ? value : null;
27
+ }
28
+
29
+ function normalizeIdentity(value) {
30
+ return nonEmptyString(typeof value === 'string' ? value.trim() : value);
31
+ }
32
+
33
+ function isUsableIdentity(value) {
34
+ const identity = normalizeIdentity(value);
35
+ return identity && !identity.startsWith('unverified:') ? identity : null;
36
+ }
37
+
38
+ function isValidManifestEntry(entry) {
39
+ if (!entry || typeof entry !== 'object') return false;
40
+ if (!nonEmptyString(entry.id) || !nonEmptyString(entry.token)) return false;
41
+ if (!['reserved', 'running'].includes(entry.status)) return false;
42
+ if (!nonEmptyString(entry.startedAt)) return false;
43
+ if (entry.instanceId != null && !nonEmptyString(entry.instanceId)) return false;
44
+ if (entry.status === 'reserved') {
45
+ if (entry.pid == null) return entry.identity == null;
46
+ return normalizePid(entry.pid) === entry.pid
47
+ && (entry.identity == null || normalizeIdentity(entry.identity) !== null);
48
+ }
49
+ return normalizePid(entry.pid) === entry.pid && normalizeIdentity(entry.identity) !== null;
50
+ }
51
+
52
+ function isValidManifest(manifest) {
53
+ return Boolean(
54
+ manifest
55
+ && manifest.version === MANIFEST_VERSION
56
+ && nonEmptyString(manifest.token)
57
+ && (manifest.instanceId == null || nonEmptyString(manifest.instanceId))
58
+ && manifest.owner
59
+ && typeof manifest.owner === 'object'
60
+ && normalizePid(manifest.owner.pid) === manifest.owner.pid
61
+ && nonEmptyString(manifest.owner.startedAt)
62
+ && (manifest.owner.identity == null || normalizeIdentity(manifest.owner.identity) !== null)
63
+ && Array.isArray(manifest.children)
64
+ && manifest.children.every(isValidManifestEntry),
65
+ );
66
+ }
67
+
68
+ function inspectResource(resourcePath, fsApi = fs) {
69
+ const stat = fsApi.lstatSync || fsApi.statSync;
70
+ if (typeof stat !== 'function') {
71
+ try {
72
+ return {
73
+ exists: typeof fsApi.existsSync === 'function' ? fsApi.existsSync(resourcePath) : true,
74
+ metadata: null,
75
+ };
76
+ } catch {
77
+ return { exists: false, metadata: null };
78
+ }
79
+ }
80
+ try {
81
+ return { exists: true, metadata: stat.call(fsApi, resourcePath) };
82
+ } catch (error) {
83
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
84
+ return { exists: false, metadata: null };
85
+ }
86
+ return { exists: null, metadata: null };
87
+ }
88
+ }
89
+
90
+ function isOwnedMetadata(metadata, processApi = process, {
91
+ allowSharedAncestor = false,
92
+ allowTrustedSymlink = false,
93
+ } = {}) {
94
+ if (!allowTrustedSymlink
95
+ && metadata && typeof metadata.isSymbolicLink === 'function' && metadata.isSymbolicLink()) return false;
96
+ if (allowTrustedSymlink) return true;
97
+ const currentUid = typeof processApi.getuid === 'function' ? processApi.getuid() : null;
98
+ if (currentUid == null || typeof metadata?.uid !== 'number' || metadata.uid === currentUid) return true;
99
+ if (!allowSharedAncestor) return false;
100
+ const mode = metadata?.mode;
101
+ if (typeof mode !== 'number') return false;
102
+ const writableByOthers = (mode & 0o022) !== 0;
103
+ const sticky = (mode & 0o1000) !== 0;
104
+ return !writableByOthers || sticky;
105
+ }
106
+
107
+ function isTrustedSystemSymlink(resourcePath, metadata, fsApi = fs) {
108
+ if (path.sep !== '/' || metadata?.uid !== 0
109
+ || typeof metadata?.isSymbolicLink !== 'function' || !metadata.isSymbolicLink()
110
+ || typeof fsApi.readlinkSync !== 'function') return false;
111
+ const normalizedPath = path.posix.normalize(resourcePath);
112
+ if (!['/var', '/tmp'].includes(normalizedPath)) return false;
113
+ let target;
114
+ try {
115
+ target = fsApi.readlinkSync(resourcePath);
116
+ } catch {
117
+ return false;
118
+ }
119
+ return path.posix.resolve(path.posix.dirname(normalizedPath), target)
120
+ === `/private${normalizedPath}`;
121
+ }
122
+
123
+ function pathComponents(resourcePath) {
124
+ const absolutePath = path.resolve(resourcePath);
125
+ const parsed = path.parse(absolutePath);
126
+ const components = [parsed.root];
127
+ let current = parsed.root;
128
+ for (const segment of absolutePath.slice(parsed.root.length).split(path.sep)) {
129
+ if (!segment) continue;
130
+ current = path.join(current, segment);
131
+ components.push(current);
132
+ }
133
+ return components;
134
+ }
135
+
136
+ function isOwnedPath(resourcePath, fsApi = fs, processApi = process) {
137
+ if (typeof resourcePath !== 'string' || resourcePath.length === 0) return false;
138
+ const stat = fsApi.lstatSync || fsApi.statSync;
139
+ if (typeof stat !== 'function') return true;
140
+ let missing = false;
141
+ const components = pathComponents(resourcePath);
142
+ for (const [index, component] of components.entries()) {
143
+ const inspected = inspectResource(component, fsApi);
144
+ if (inspected.exists === false) {
145
+ missing = true;
146
+ continue;
147
+ }
148
+ const allowSharedAncestor = index < components.length - 1;
149
+ const trustedSystemSymlink = allowSharedAncestor
150
+ && isTrustedSystemSymlink(component, inspected.metadata, fsApi);
151
+ if (inspected.exists !== true || missing || !isOwnedMetadata(inspected.metadata, processApi, {
152
+ allowSharedAncestor,
153
+ allowTrustedSymlink: trustedSystemSymlink,
154
+ })) {
155
+ return false;
156
+ }
157
+ }
158
+ return true;
159
+ }
160
+
161
+ function isOwnedManifestPath(manifestPath, fsApi = fs, processApi = process) {
162
+ return isOwnedPath(path.dirname(manifestPath), fsApi, processApi)
163
+ && isOwnedPath(manifestPath, fsApi, processApi);
164
+ }
165
+
166
+ function readProcessManifest(manifestPath, fsApi = fs, processApi = process) {
167
+ if (typeof manifestPath !== 'string' || manifestPath.length === 0) return null;
168
+ if (!isOwnedManifestPath(manifestPath, fsApi, processApi)) return null;
169
+ try {
170
+ const manifest = JSON.parse(fsApi.readFileSync(manifestPath, 'utf8'));
171
+ return isValidManifest(manifest) ? manifest : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function writeProcessManifest(manifestPath, manifest, fsApi = fs, processApi = process) {
178
+ const manifestDir = path.dirname(manifestPath);
179
+ if (!isOwnedPath(manifestPath, fsApi, processApi)) {
180
+ throw new Error('process manifest path is not owned by the current user');
181
+ }
182
+ let directory = inspectResource(manifestDir, fsApi);
183
+ if (directory.exists === null) throw new Error('process manifest directory cannot be inspected');
184
+ if (directory.exists === false) {
185
+ fsApi.mkdirSync(manifestDir, { recursive: true, mode: 0o700 });
186
+ directory = inspectResource(manifestDir, fsApi);
187
+ }
188
+ if (directory.exists !== true
189
+ || !isOwnedPath(manifestDir, fsApi, processApi)
190
+ || (typeof directory.metadata?.isDirectory === 'function' && !directory.metadata.isDirectory())) {
191
+ throw new Error('process manifest directory is not a user-owned directory');
192
+ }
193
+ if (typeof fsApi.chmodSync === 'function') {
194
+ if (!isOwnedPath(manifestDir, fsApi, processApi)) {
195
+ throw new Error('process manifest directory is not a user-owned directory');
196
+ }
197
+ fsApi.chmodSync(manifestDir, 0o700);
198
+ }
199
+ if (!isOwnedPath(manifestPath, fsApi, processApi)) {
200
+ throw new Error('process manifest path is not owned by the current user');
201
+ }
202
+ fsApi.writeFileSync(manifestPath, JSON.stringify(manifest), 'utf8');
203
+ }
204
+
205
+ function pathExists(manifestPath, fsApi = fs) {
206
+ try {
207
+ if (typeof fsApi.existsSync === 'function') return fsApi.existsSync(manifestPath);
208
+ fsApi.accessSync(manifestPath);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ function defaultIsAlive(pid, processApi) {
216
+ try {
217
+ processApi.kill(pid, 0);
218
+ return true;
219
+ } catch (error) {
220
+ return error && error.code === 'EPERM';
221
+ }
222
+ }
223
+
224
+ function readProcIdentity(pid, fsApi) {
225
+ try {
226
+ const stat = fsApi.readFileSync(`/proc/${pid}/stat`, 'utf8');
227
+ const closeParen = stat.lastIndexOf(')');
228
+ if (closeParen >= 0) {
229
+ const startTime = nonEmptyString(stat.slice(closeParen + 2).trim().split(/\s+/)[19]);
230
+ if (startTime) return `proc:${startTime}`;
231
+ }
232
+ } catch {
233
+ // Fall through to ps on platforms without procfs.
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function readPsIdentity(pid, spawnSync) {
239
+ try {
240
+ const result = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
241
+ shell: false, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', windowsHide: true,
242
+ });
243
+ const value = result && !result.error && result.status === 0
244
+ ? normalizeIdentity(result.stdout) : null;
245
+ return value ? `ps:${value}` : null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function readWindowsIdentity(pid, spawnSync) {
252
+ try {
253
+ const result = spawnSync('powershell.exe', [
254
+ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command',
255
+ `(Get-Process -Id ${String(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
256
+ ], { shell: false, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', windowsHide: true });
257
+ const value = result && !result.error && result.status === 0
258
+ ? normalizeIdentity(result.stdout) : null;
259
+ return value ? `windows:${value}` : null;
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+
265
+ function defaultGetProcessIdentity(pid, options = {}) {
266
+ const normalizedPid = normalizePid(pid);
267
+ if (!normalizedPid) return null;
268
+ const platform = options.platform || process.platform;
269
+ const fsApi = options.fsApi || fs;
270
+ const spawnSync = options.spawnSync || defaultSpawnSync;
271
+ if (platform !== 'win32') return readProcIdentity(normalizedPid, fsApi)
272
+ || readPsIdentity(normalizedPid, spawnSync);
273
+ return readWindowsIdentity(normalizedPid, spawnSync);
274
+ }
275
+
276
+ function signalExitCode(signal) {
277
+ if (signal === 'SIGINT') return 130;
278
+ if (signal === 'SIGTERM') return 143;
279
+ return 1;
280
+ }
281
+
282
+ function createInitialManifest(token, processApi, getProcessIdentity, instanceId) {
283
+ const ownerPid = normalizePid(processApi.pid);
284
+ let ownerIdentity;
285
+ try {
286
+ ownerIdentity = normalizeIdentity(getProcessIdentity(ownerPid));
287
+ } catch {
288
+ ownerIdentity = null;
289
+ }
290
+ return {
291
+ version: MANIFEST_VERSION,
292
+ token,
293
+ instanceId,
294
+ owner: {
295
+ pid: ownerPid,
296
+ identity: ownerIdentity,
297
+ startedAt: nowIso(),
298
+ },
299
+ children: [],
300
+ };
301
+ }
302
+
303
+ function createProcessTree(options = {}) {
304
+ const fsApi = options.fsApi || fs;
305
+ const processApi = options.processApi || process;
306
+ const platform = options.platform || process.platform;
307
+ const spawnSync = options.spawnSync || defaultSpawnSync;
308
+ const env = options.env || process.env;
309
+ const allInstances = options.allInstances === true;
310
+ const instanceId = allInstances ? null : String(options.instanceId || randomUUID());
311
+ const hasCustomIdentity = typeof options.getProcessIdentity === 'function';
312
+ const getProcessIdentity = options.getProcessIdentity || ((pid) => defaultGetProcessIdentity(pid, {
313
+ platform,
314
+ fsApi,
315
+ spawnSync,
316
+ }));
317
+ const captureIdentity = (pid) => {
318
+ let identity;
319
+ try {
320
+ identity = normalizeIdentity(getProcessIdentity(pid));
321
+ } catch {
322
+ identity = null;
323
+ }
324
+ // Synthetic child handles may not be visible to the host process table.
325
+ // Keep their reservation, but never treat this marker as kill
326
+ // authorization if no stable evidence was available.
327
+ if (!identity && !hasCustomIdentity) return `unverified:${pid}`;
328
+ return identity;
329
+ };
330
+ // A Forge claim records FORGE_SESSION_ID. Use that same stable runtime token
331
+ // for the root manifest, while preserving an inherited manifest token for
332
+ // nested process trees that are already attached to their parent run.
333
+ const inheritedToken = nonEmptyString(options.token) || nonEmptyString(env[TOKEN_ENV]);
334
+ const token = String(inheritedToken || env[SESSION_ENV] || randomUUID());
335
+ const explicitManifestPath = nonEmptyString(options.manifestPath);
336
+ const environmentManifestPath = nonEmptyString(env[MANIFEST_ENV]);
337
+ const manifestDir = options.manifestDir || DEFAULT_MANIFEST_DIR;
338
+
339
+ if (!explicitManifestPath && !environmentManifestPath && options.reconcile !== false) {
340
+ reconcileProcessManifests({
341
+ ...options,
342
+ manifestDir,
343
+ fsApi,
344
+ platform,
345
+ processApi,
346
+ spawnSync,
347
+ getProcessIdentity,
348
+ allInstances: true,
349
+ });
350
+ }
351
+
352
+ const manifestPath = explicitManifestPath
353
+ || environmentManifestPath
354
+ || path.join(manifestDir, `run-${normalizePid(processApi.pid) || process.pid}-${token}.json`);
355
+ const markerExists = pathExists(manifestPath, fsApi);
356
+ const existing = readProcessManifest(manifestPath, fsApi, processApi);
357
+ // An environment-provided path may belong to another run. Treat that as
358
+ // unverifiable and fail closed; never overwrite or reap another run's marker.
359
+ const inheritedManifestVerified = explicitManifestPath
360
+ || !environmentManifestPath
361
+ || Boolean(inheritedToken && existing?.token === inheritedToken);
362
+ const usable = inheritedManifestVerified
363
+ && (!markerExists || (existing && existing.token === token));
364
+ let manifest = existing && existing.token === token
365
+ ? existing
366
+ : (usable ? createInitialManifest(token, processApi, getProcessIdentity, instanceId) : null);
367
+ let sequence = manifest ? manifest.children.length : 0;
368
+ let cleanupSignal = null;
369
+ let cleanupComplete = false;
370
+
371
+ function ownsEntry(entry, latest = manifest) {
372
+ return Boolean(allInstances || (entry && entry.instanceId === instanceId)
373
+ || (entry && entry.instanceId == null && latest?.instanceId === instanceId));
374
+ }
375
+
376
+ if (usable) {
377
+ try {
378
+ writeProcessManifest(manifestPath, manifest, fsApi, processApi);
379
+ } catch {
380
+ // A missing/unwritable marker makes ownership unverifiable. All cleanup
381
+ // operations remain fail-closed, and the caller still receives its normal
382
+ // child-process status.
383
+ manifest = null;
384
+ }
385
+ } else {
386
+ manifest = null;
387
+ }
388
+
389
+ function currentManifest() {
390
+ if (!usable || !manifest) return null;
391
+ const latest = readProcessManifest(manifestPath, fsApi, processApi);
392
+ return latest && latest.token === token ? latest : null;
393
+ }
394
+
395
+ function persist(next) {
396
+ if (!next || !usable || !manifest) return false;
397
+ try {
398
+ writeProcessManifest(manifestPath, next, fsApi, processApi);
399
+ manifest = next;
400
+ return true;
401
+ } catch {
402
+ manifest = null;
403
+ return false;
404
+ }
405
+ }
406
+
407
+ function reserveChild(metadata = {}) {
408
+ const latest = currentManifest();
409
+ if (!latest) return null;
410
+ const entry = {
411
+ id: `${token}:${sequence++}:${randomUUID()}`,
412
+ token,
413
+ instanceId,
414
+ kind: metadata.kind || 'child',
415
+ label: metadata.label || null,
416
+ command: metadata.command || null,
417
+ pid: null,
418
+ status: 'reserved',
419
+ startedAt: nowIso(),
420
+ registeredAt: null,
421
+ identity: null,
422
+ };
423
+ latest.children.push(entry);
424
+ return persist(latest) ? { id: entry.id } : null;
425
+ }
426
+
427
+ function registerChild(reservation, child) {
428
+ const latest = currentManifest();
429
+ const id = reservation && reservation.id;
430
+ const pid = normalizePid(child && child.pid);
431
+ if (!latest || typeof id !== 'string' || !pid) return null;
432
+ const entry = latest.children.find((candidate) => candidate.id === id);
433
+ if (!entry || !ownsEntry(entry, latest) || entry.token !== token || entry.status !== 'reserved') return null;
434
+ const identity = captureIdentity(pid);
435
+ if (!identity) return null;
436
+ entry.pid = pid;
437
+ entry.identity = identity;
438
+ entry.status = 'running';
439
+ entry.registeredAt = nowIso();
440
+ return persist(latest) ? { ...entry } : null;
441
+ }
442
+
443
+ function unregisterChild(reservation) {
444
+ const latest = currentManifest();
445
+ const id = reservation && reservation.id;
446
+ if (!latest || typeof id !== 'string') return false;
447
+ const entry = latest.children.find((candidate) => candidate.id === id);
448
+ if (!ownsEntry(entry, latest)) return false;
449
+ const next = { ...latest, children: latest.children.filter((candidate) => candidate.id !== id) };
450
+ return persist(next);
451
+ }
452
+
453
+ function envFor(childEnv = env) {
454
+ if (!usable || !manifest) {
455
+ const stripped = { ...childEnv };
456
+ delete stripped[MANIFEST_ENV];
457
+ delete stripped[TOKEN_ENV];
458
+ delete stripped[INSTANCE_ENV];
459
+ return stripped;
460
+ }
461
+ return {
462
+ ...childEnv,
463
+ [MANIFEST_ENV]: manifestPath,
464
+ [TOKEN_ENV]: token,
465
+ [INSTANCE_ENV]: instanceId,
466
+ };
467
+ }
468
+
469
+ function isVerifiableEntry(latest, entry) {
470
+ if (!(
471
+ latest
472
+ && latest.version === MANIFEST_VERSION
473
+ && latest.token === token
474
+ && entry
475
+ && entry.token === token
476
+ && ownsEntry(entry, latest)
477
+ && typeof entry.id === 'string'
478
+ && entry.status === 'running'
479
+ && normalizePid(entry.pid) === entry.pid
480
+ && typeof entry.startedAt === 'string'
481
+ && entry.startedAt.length > 0
482
+ )) return false;
483
+
484
+ const storedIdentity = isUsableIdentity(entry.identity);
485
+ if (!storedIdentity) return false;
486
+ let currentIdentity;
487
+ try {
488
+ currentIdentity = isUsableIdentity(getProcessIdentity(entry.pid));
489
+ } catch {
490
+ currentIdentity = null;
491
+ }
492
+ return currentIdentity !== null && currentIdentity === storedIdentity;
493
+ }
494
+
495
+ function isEntryAlive(entry) {
496
+ try {
497
+ return (options.isAlive || defaultIsAlive)(entry.pid, processApi);
498
+ } catch {
499
+ return true;
500
+ }
501
+ }
502
+
503
+ function killOwnedEntry(entry, signal) {
504
+ const pid = normalizePid(entry && entry.pid);
505
+ if (!pid || pid === normalizePid(processApi.pid) || !isVerifiableEntry(currentManifest(), entry)) {
506
+ return false;
507
+ }
508
+ if (!(options.isAlive || defaultIsAlive)(pid, processApi)) return false;
509
+
510
+ try {
511
+ if (platform === 'win32') {
512
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
513
+ shell: false,
514
+ stdio: 'ignore',
515
+ windowsHide: true,
516
+ });
517
+ return Boolean(result && !result.error && result.status === 0);
518
+ }
519
+ processApi.kill(-pid, signal);
520
+ return true;
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+
526
+ function cleanup(signal = 'SIGTERM') {
527
+ const force = signal === 'SIGKILL';
528
+ if (cleanupComplete || (cleanupSignal === signal && !force)) {
529
+ return { killed: [] };
530
+ }
531
+ cleanupSignal = signal;
532
+ const latest = currentManifest();
533
+ if (!latest) return { killed: [] };
534
+
535
+ const killed = [];
536
+ for (const entry of latest.children) {
537
+ if (killOwnedEntry(entry, signal)) killed.push(entry.pid);
538
+ }
539
+
540
+ const verifiedCache = new Map();
541
+ const aliveCache = new Map();
542
+ const verifiedOnce = (entry, manifestForEntry) => {
543
+ if (!verifiedCache.has(entry.id)) {
544
+ verifiedCache.set(entry.id, isVerifiableEntry(manifestForEntry, entry));
545
+ }
546
+ return verifiedCache.get(entry.id);
547
+ };
548
+ const aliveOnce = (entry) => {
549
+ if (!aliveCache.has(entry.id)) aliveCache.set(entry.id, isEntryAlive(entry));
550
+ return aliveCache.get(entry.id);
551
+ };
552
+ let finalManifest = currentManifest();
553
+ if (finalManifest) {
554
+ const liveEntries = finalManifest.children.filter((entry) => !(
555
+ entry.status === 'running'
556
+ && !verifiedOnce(entry, finalManifest)
557
+ && !aliveOnce(entry)
558
+ ));
559
+ if (liveEntries.length !== finalManifest.children.length) {
560
+ persist({ ...finalManifest, children: liveEntries });
561
+ finalManifest = currentManifest();
562
+ }
563
+ }
564
+ const hasUnverifiableLiveEntry = finalManifest?.children.some(
565
+ (entry) => entry.status === 'running' && !verifiedOnce(entry, finalManifest)
566
+ && aliveOnce(entry),
567
+ );
568
+ const hasLiveRunningEntry = finalManifest?.children.some(
569
+ (entry) => entry.status === 'running'
570
+ && verifiedOnce(entry, finalManifest)
571
+ && aliveOnce(entry),
572
+ );
573
+ const hasOutstandingReservation = finalManifest?.children.some(
574
+ (entry) => entry.status === 'reserved',
575
+ );
576
+ const hasFailedLiveEntry = finalManifest?.children.some(
577
+ (entry) => entry.status === 'running'
578
+ && verifiedOnce(entry, finalManifest)
579
+ && aliveOnce(entry)
580
+ && !killed.includes(entry.pid),
581
+ );
582
+ const safeToRemove = finalManifest
583
+ && !hasUnverifiableLiveEntry
584
+ && !hasOutstandingReservation
585
+ && !hasFailedLiveEntry
586
+ && (force || !hasLiveRunningEntry);
587
+ if (safeToRemove && finalManifest.token === token) {
588
+ try {
589
+ fsApi.rmSync(manifestPath, { force: true });
590
+ cleanupComplete = !pathExists(manifestPath, fsApi);
591
+ } catch {
592
+ // A stale marker is safe to leave behind; its token still prevents a
593
+ // later run from touching this process tree.
594
+ }
595
+ }
596
+ return { killed };
597
+ }
598
+
599
+ function abortChild(reservation, child, signal = 'SIGKILL') {
600
+ const pid = normalizePid(child && child.pid);
601
+ const retainReservation = (identityOverride = null) => {
602
+ const latest = currentManifest();
603
+ const id = reservation && reservation.id;
604
+ const entry = latest?.children.find((candidate) => candidate.id === id);
605
+ if (!latest || !entry || !ownsEntry(entry, latest) || entry.token !== token || entry.status !== 'reserved') {
606
+ return false;
607
+ }
608
+ entry.pid = pid;
609
+ entry.identity = identityOverride || captureIdentity(pid) || `unverified:${pid}`;
610
+ entry.status = 'running';
611
+ entry.registeredAt = nowIso();
612
+ persist(latest);
613
+ return true;
614
+ };
615
+
616
+ // Capture stable identity before direct kill so the process-group/tree kill
617
+ // can reap descendants while the reservation is still verifiable.
618
+ const identity = pid ? captureIdentity(pid) : null;
619
+ const retained = pid && identity ? retainReservation(identity) : false;
620
+ const finalizeAbort = (result) => {
621
+ if (pid && !retained) retainReservation(identity);
622
+ cleanup('SIGKILL');
623
+ unregisterChild(reservation);
624
+ cleanup('SIGKILL');
625
+ return result;
626
+ };
627
+ const groupResult = retained ? cleanup(signal) : { killed: [] };
628
+ if (pid && groupResult.killed.includes(pid)) {
629
+ unregisterChild(reservation);
630
+ cleanup('SIGKILL');
631
+ return true;
632
+ }
633
+
634
+ let killSucceeded = false;
635
+ try {
636
+ if (child && typeof child.kill === 'function') {
637
+ killSucceeded = child.kill(signal) === true;
638
+ }
639
+ } catch {
640
+ killSucceeded = false;
641
+ }
642
+
643
+ if (killSucceeded || !pid) return finalizeAbort(killSucceeded);
644
+
645
+ const alive = (() => {
646
+ try {
647
+ return (options.isAlive || defaultIsAlive)(normalizePid(child.pid), processApi);
648
+ } catch {
649
+ return true;
650
+ }
651
+ })();
652
+ if (!alive) return finalizeAbort(killSucceeded);
653
+
654
+ if (!retained) retainReservation(identity);
655
+ if (currentManifest()?.children.some((entry) => entry.pid === pid && entry.status === 'running')) {
656
+ // If identity evidence was available, cleanup can attempt a verified
657
+ // tree kill. Otherwise retain the reservation for later reconciliation.
658
+ cleanup('SIGKILL');
659
+ }
660
+ return killSucceeded;
661
+ }
662
+
663
+ function installSignalHandlers(onSignal) {
664
+ if (!processApi || typeof processApi.on !== 'function') return () => {};
665
+ const handlers = new Map();
666
+ for (const signal of ['SIGINT', 'SIGTERM']) {
667
+ const handler = () => {
668
+ cleanup(signal === 'SIGINT' ? 'SIGTERM' : signal);
669
+ if (typeof onSignal === 'function') onSignal(signal);
670
+ else processApi.exitCode = signalExitCode(signal);
671
+ };
672
+ handlers.set(signal, handler);
673
+ processApi.on(signal, handler);
674
+ }
675
+ return () => {
676
+ if (typeof processApi.removeListener !== 'function') return;
677
+ for (const [signal, handler] of handlers) processApi.removeListener(signal, handler);
678
+ };
679
+ }
680
+
681
+ return {
682
+ cleanup,
683
+ envFor,
684
+ installSignalHandlers,
685
+ registerChild,
686
+ reserveChild,
687
+ unregisterChild,
688
+ abortChild,
689
+ manifestPath,
690
+ instanceId,
691
+ token,
692
+ };
693
+ }
694
+
695
+ function reapProcessManifest(manifestPath, options = {}) {
696
+ const fsApi = options.fsApi || fs;
697
+ const processApi = options.processApi || process;
698
+ const existing = readProcessManifest(manifestPath, fsApi, processApi);
699
+ if (!existing) return { killed: [], reaped: false };
700
+
701
+ const ownerPid = normalizePid(existing.owner.pid);
702
+ const currentPid = normalizePid(processApi.pid);
703
+ if (!ownerPid || ownerPid === currentPid) return { killed: [], reaped: false };
704
+
705
+ const isAlive = options.isAlive || defaultIsAlive;
706
+ const ownerAlive = (() => {
707
+ try {
708
+ return isAlive(ownerPid, processApi);
709
+ } catch {
710
+ return true;
711
+ }
712
+ })();
713
+ if (ownerAlive) {
714
+ // A live owner is not an orphan. Treat identity uncertainty as live too;
715
+ // this avoids killing a process that reused the owner's PID.
716
+ return { killed: [], reaped: false };
717
+ }
718
+
719
+ const tree = createProcessTree({
720
+ ...options,
721
+ manifestPath,
722
+ token: existing.token,
723
+ allInstances: true,
724
+ reconcile: false,
725
+ });
726
+ const result = tree.cleanup(options.signal || 'SIGKILL');
727
+ return { ...result, reaped: !pathExists(manifestPath, fsApi) };
728
+ }
729
+
730
+ function reconcileProcessManifests(options = {}) {
731
+ const fsApi = options.fsApi || fs;
732
+ const processApi = options.processApi || process;
733
+ const manifestDir = options.manifestDir || DEFAULT_MANIFEST_DIR;
734
+ if (!isOwnedPath(manifestDir, fsApi, processApi)) return { reaped: 0 };
735
+ let entries;
736
+ try {
737
+ entries = fsApi.readdirSync(manifestDir, { withFileTypes: true });
738
+ } catch {
739
+ return { reaped: 0 };
740
+ }
741
+
742
+ let reaped = 0;
743
+ for (const entry of entries) {
744
+ const name = typeof entry === 'string' ? entry : entry?.name;
745
+ if (!name || !name.endsWith('.json')) continue;
746
+ if (typeof entry !== 'string' && entry.isFile && !entry.isFile()) continue;
747
+ const manifestPath = path.join(manifestDir, name);
748
+ const result = reapProcessManifest(manifestPath, {
749
+ ...options,
750
+ fsApi,
751
+ processApi,
752
+ manifestDir,
753
+ reconcile: false,
754
+ });
755
+ if (result.reaped) reaped += 1;
756
+ }
757
+ return { reaped };
758
+ }
759
+
760
+ module.exports = {
761
+ DEFAULT_MANIFEST_DIR,
762
+ INSTANCE_ENV,
763
+ MANIFEST_ENV,
764
+ MANIFEST_VERSION,
765
+ TOKEN_ENV,
766
+ createProcessTree,
767
+ defaultGetProcessIdentity,
768
+ isValidManifest,
769
+ readProcessManifest,
770
+ reapProcessManifest,
771
+ reconcileProcessManifests,
772
+ signalExitCode,
773
+ };