@codedology/code-forge 0.1.0

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 (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Per-run temp root (plan §9.6 rule 2, V12).
3
+ *
4
+ * Every temp path the package creates lives under ONE directory per run:
5
+ * `<base>/<run-id>/`, where `<base>` is the `tmp.root` config value when the caller passes it and
6
+ * `<os.tmpdir()>/code-forge` otherwise. The run root holds `owner.json` (`{pid, start_time,
7
+ * created_at}` of the process that created it) and the pid registry `pids/` that `exec` fills.
8
+ * A killed run therefore leaves one directory, not thousands, and the next `run start` (or the
9
+ * test preload) calls `sweepRoots()`, which reaps the dead run's registered children
10
+ * (`./reaper.mjs`) and removes the directory. A root whose owner is alive, or whose owner cannot
11
+ * be read, is kept.
12
+ *
13
+ * Trust rule: the base directory and every run root must be a real directory (not a symlink)
14
+ * owned by this user and not writable by group or others. On a shared `/tmp` another user could
15
+ * otherwise plant a root with `pids/` entries and make the reaper signal our processes. `runRoot`
16
+ * throws on an untrusted directory; `sweepRoots` reports it as `unknown` and never reaps it.
17
+ * `owner.json` is written atomically (temp file in the root, then rename), so a parallel sweep
18
+ * reads either the previous owner record or the new one, never a truncated one. An owner file
19
+ * that exists but cannot be parsed, or names an invalid pid, is `unknown` and never reaped. Only a
20
+ * root with NO owner file (ENOENT) uses the ownerless path: it is removed once nothing a live run
21
+ * touches (the root, `pids/`, its entries) has changed for `ownerlessGraceMs` (a root being
22
+ * created right now has no owner file for a moment).
23
+ *
24
+ * Claim rule: a sweep never reaps a root in place. It first renames the root to a tombstone
25
+ * (`.reaping-<random>`, a name no run id can ever take: RUN_ID rejects a leading `.`), re-reads
26
+ * the owner on the renamed path, and only then reaps and removes it. A run that re-adopted the
27
+ * same run id in between (a resumed run) therefore either owns a fresh directory (its
28
+ * `runRoot` retries after the rename) or is found alive on the tombstone, which is put back.
29
+ *
30
+ * A process that never picked a run root gets a lazy one (`proc-<pid>-<ms>`) the first time
31
+ * `currentRunRoot()` is called, so `exec` always has somewhere to register its children. The lazy
32
+ * root lives under the base named by `CODE_FORGE_TMP_ROOT` when that is an absolute path (the CLI
33
+ * router sets it from the project's `tmp.root`, B19), else under `<os.tmpdir()>/code-forge`. On a
34
+ * normal process exit the lazy root is removed when its `pids/` holds no live entry
35
+ * (`releaseLazyRoot`); a root with a live child, or an entry it cannot read, is kept for the sweep.
36
+ */
37
+
38
+ import { randomBytes } from 'node:crypto';
39
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+ import { isAlive, listEntries, readStartTime, sweep, UNKNOWN_START_TIME } from './reaper.mjs';
43
+
44
+ /** Name of the directory under `os.tmpdir()` used when no `tmp.root` is configured. */
45
+ export const DEFAULT_BASE_NAME = 'code-forge';
46
+
47
+ /** File inside a run root naming the process that owns it. */
48
+ export const OWNER_FILE = 'owner.json';
49
+
50
+ /** A run id is one plain path segment: no separators, no `.`/`..`. */
51
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
52
+
53
+ /** An ownerless root younger than this is being created, not abandoned. */
54
+ const DEFAULT_OWNERLESS_GRACE_MS = 10 * 60 * 1000;
55
+
56
+ /** Prefix of a root claimed by a sweep; starts with `.` so `RUN_ID` can never produce it. */
57
+ const TOMBSTONE_PREFIX = '.reaping-';
58
+
59
+ /** Environment variable naming the base directory when no `root` is passed (the CLI's `tmp.root`). */
60
+ export const TMP_ROOT_ENV = 'CODE_FORGE_TMP_ROOT';
61
+
62
+ /** @type {string | null} */
63
+ let current = null;
64
+
65
+ /** The lazy root this process created (`proc-<pid>-<ms>`), or null. */
66
+ /** @type {string | null} */
67
+ let lazy = null;
68
+ let exitHooked = false;
69
+
70
+ /**
71
+ * Why `dir` is not a trusted directory, or null when it is: must exist, not be a symlink, be
72
+ * owned by this user and have none of the `mask` permission bits set (default: no group/other
73
+ * write bit; pass `0o077` for a directory that must be private).
74
+ * @param {string} dir
75
+ * @param {number} [mask]
76
+ * @returns {string | null}
77
+ */
78
+ export function untrustedReason(dir, mask = 0o022) {
79
+ let st;
80
+ try {
81
+ st = lstatSync(dir);
82
+ } catch {
83
+ return 'missing';
84
+ }
85
+ if (st.isSymbolicLink()) return 'symlink';
86
+ if (!st.isDirectory()) return 'not a directory';
87
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) return 'foreign owner';
88
+ if ((st.mode & mask) !== 0) return mask === 0o022 ? 'group/other writable' : 'group/other accessible';
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Write `<dir>/owner.json` atomically: a fresh temp file in the same directory, then rename over
94
+ * the final name. On any failure the temp file is removed, so no partial file is ever left.
95
+ * @param {string} dir
96
+ * @param {{pid: number, start_time: string, created_at: string}} owner
97
+ */
98
+ function writeOwnerAtomic(dir, owner) {
99
+ const temp = path.join(dir, `.${OWNER_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
100
+ try {
101
+ writeFileSync(temp, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: 'wx' });
102
+ renameSync(temp, path.join(dir, OWNER_FILE));
103
+ } catch (err) {
104
+ rmSync(temp, { force: true });
105
+ throw err;
106
+ }
107
+ }
108
+
109
+ /** @param {string} dir @param {string} what */
110
+ function assertTrusted(dir, what) {
111
+ const reason = untrustedReason(dir);
112
+ if (reason !== null) throw new Error(`tmp: refusing untrusted ${what} (${reason}): ${dir}`);
113
+ }
114
+
115
+ /**
116
+ * The directory run roots live in.
117
+ * @param {string} [root] - the `tmp.root` config value; must be absolute when given.
118
+ * @returns {string}
119
+ */
120
+ export function tmpBase(root) {
121
+ if (root === undefined || root === null) {
122
+ const fromEnv = process.env[TMP_ROOT_ENV];
123
+ if (typeof fromEnv === 'string' && path.isAbsolute(fromEnv)) return fromEnv;
124
+ return path.join(os.tmpdir(), DEFAULT_BASE_NAME);
125
+ }
126
+ if (typeof root !== 'string' || !path.isAbsolute(root)) {
127
+ throw new TypeError('tmp: tmp.root must be an absolute path');
128
+ }
129
+ return root;
130
+ }
131
+
132
+ /**
133
+ * Create (if needed) the run root `<base>/<runId>/`, record this process as its owner, and
134
+ * return its real absolute path.
135
+ * @param {string} runId
136
+ * @param {{root?: string}} [opts]
137
+ * @returns {string}
138
+ */
139
+ export function runRoot(runId, opts = {}) {
140
+ if (typeof runId !== 'string' || !RUN_ID.test(runId)) {
141
+ throw new TypeError('tmp: runId must be one path segment of letters, digits, ".", "_" or "-"');
142
+ }
143
+ const base = tmpBase(opts.root);
144
+ const dir = path.join(base, runId);
145
+ // A parallel sweep that claimed a dead root of this name renames it away (see the claim rule)
146
+ // between our mkdir and our write: the directory is then "missing" or the write fails with
147
+ // ENOENT, and the retry creates a fresh directory in its place. A tombstone itself can never be
148
+ // adopted here: its name starts with `.`, which RUN_ID rejects above.
149
+ for (let attempt = 0; ; attempt++) {
150
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
151
+ assertTrusted(base, 'tmp base');
152
+ const reason = untrustedReason(dir);
153
+ if (reason === 'missing' && attempt < 2) continue;
154
+ if (reason !== null) throw new Error(`tmp: refusing untrusted run root (${reason}): ${dir}`);
155
+ try {
156
+ writeOwnerAtomic(dir, {
157
+ pid: process.pid,
158
+ start_time: readStartTime(process.pid) ?? UNKNOWN_START_TIME,
159
+ created_at: new Date().toISOString(),
160
+ });
161
+ return realpathSync(dir);
162
+ } catch (err) {
163
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== 'ENOENT' || attempt >= 2) throw err;
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Make `dir` this process's run root (`null` forgets it; the next `currentRunRoot()` makes a lazy one).
170
+ * @param {string | null} dir
171
+ */
172
+ export function setRunRoot(dir) {
173
+ if (dir !== null && (typeof dir !== 'string' || !path.isAbsolute(dir))) {
174
+ throw new TypeError('tmp: a run root must be an absolute path');
175
+ }
176
+ current = dir;
177
+ }
178
+
179
+ /** @returns {string} this process's run root, created lazily as `proc-<pid>-<ms>`. */
180
+ export function currentRunRoot() {
181
+ if (current === null) {
182
+ current = runRoot(`proc-${process.pid}-${Date.now()}`);
183
+ lazy = current;
184
+ if (!exitHooked) {
185
+ exitHooked = true;
186
+ process.once('exit', () => {
187
+ releaseLazyRoot();
188
+ });
189
+ }
190
+ }
191
+ return current;
192
+ }
193
+
194
+ /**
195
+ * Remove this process's lazy root when its pid registry holds no live entry: every file in
196
+ * `pids/` must be a well-formed entry whose pid is dead. Anything else (a live child, an entry
197
+ * that cannot be read) keeps the root for the next sweep. Called from the `exit` hook, so it NEVER
198
+ * throws: any error keeps the root and returns false.
199
+ * @param {{rmSync?: typeof rmSync, listEntries?: typeof listEntries}} [deps] - test seams.
200
+ * @returns {boolean} true when the lazy root was removed.
201
+ */
202
+ export function releaseLazyRoot(deps = {}) {
203
+ try {
204
+ if (lazy === null) return false;
205
+ const dir = lazy;
206
+ const pids = path.join(dir, 'pids');
207
+ const names = safeReaddir(pids);
208
+ const entries = (deps.listEntries ?? listEntries)(pids);
209
+ if (entries.length !== names.length || entries.some((e) => isAlive(e.pid))) return false;
210
+ (deps.rmSync ?? rmSync)(dir, { recursive: true, force: true });
211
+ if (current === dir) current = null;
212
+ lazy = null;
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The pid-registry directory of a run root.
221
+ * @param {string} [root] - defaults to `currentRunRoot()`.
222
+ * @returns {string}
223
+ */
224
+ export function pidsDir(root) {
225
+ return path.join(root ?? currentRunRoot(), 'pids');
226
+ }
227
+
228
+ /**
229
+ * The state of a run root's recorded owner:
230
+ * - `alive`: the recorded pid is a live process with the recorded start time;
231
+ * - `dead`: gone, or its pid now names another process;
232
+ * - `missing`: there is no owner file (ENOENT) — the root is being created, or was abandoned;
233
+ * - `invalid`: an owner file exists but is unreadable, not JSON, or names an invalid pid.
234
+ * @param {string} dir
235
+ * @returns {'alive' | 'dead' | 'missing' | 'invalid'}
236
+ */
237
+ export function ownerState(dir) {
238
+ let text;
239
+ try {
240
+ text = readFileSync(path.join(dir, OWNER_FILE), 'utf8');
241
+ } catch (err) {
242
+ return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? 'missing' : 'invalid';
243
+ }
244
+ let owner;
245
+ try {
246
+ owner = JSON.parse(text);
247
+ } catch {
248
+ return 'invalid';
249
+ }
250
+ if (!owner || !Number.isInteger(owner.pid) || owner.pid <= 1) return 'invalid';
251
+ if (!isAlive(owner.pid)) return 'dead';
252
+ if (typeof owner.start_time === 'string' && owner.start_time !== UNKNOWN_START_TIME) {
253
+ const live = readStartTime(owner.pid);
254
+ if (live !== null && live !== owner.start_time) return 'dead';
255
+ }
256
+ return 'alive';
257
+ }
258
+
259
+ /**
260
+ * Whether the recorded owner of a run root is still the same live process.
261
+ * `true` = alive, `false` = dead, `null` = unknown (owner file missing or invalid).
262
+ * @param {string} dir
263
+ * @returns {boolean | null}
264
+ */
265
+ export function ownerAlive(dir) {
266
+ const state = ownerState(dir);
267
+ return state === 'alive' ? true : state === 'dead' ? false : null;
268
+ }
269
+
270
+ /**
271
+ * Remove sibling run roots whose owner is dead, after reaping their registered children.
272
+ * This process's own root is never touched; untrusted roots (and every root of an untrusted
273
+ * base) and roots with an invalid owner file are `unknown` and never reaped; a root with no
274
+ * owner file is removed only once its last activity is older than `ownerlessGraceMs`. Every
275
+ * reap goes through the claim rule (rename to a tombstone, re-check, then reap); tombstones
276
+ * left by a killed sweep are visited whatever the `prefix`.
277
+ * @param {{root?: string, prefix?: string, graceMs?: number, ownerlessGraceMs?: number}} [opts] -
278
+ * `root` is the base directory (default `tmpBase()`); `prefix` limits the sweep to entries
279
+ * starting with it.
280
+ * @returns {Promise<{removed: string[], kept: string[], unknown: string[]}>} absolute paths.
281
+ */
282
+ export async function sweepRoots(opts = {}) {
283
+ const base = opts.root ?? tmpBase();
284
+ const prefix = opts.prefix ?? '';
285
+ const ownerlessGraceMs = opts.ownerlessGraceMs ?? DEFAULT_OWNERLESS_GRACE_MS;
286
+ /** @type {{removed: string[], kept: string[], unknown: string[]}} */
287
+ const out = { removed: [], kept: [], unknown: [] };
288
+ /** @type {import('node:fs').Dirent[]} */
289
+ let entries;
290
+ try {
291
+ entries = readdirSync(base, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
292
+ } catch {
293
+ return out;
294
+ }
295
+ const own = current === null ? null : path.resolve(current);
296
+ const baseTrusted = untrustedReason(base) === null;
297
+ for (const entry of entries) {
298
+ if (!(entry.name.startsWith(prefix) || entry.name.startsWith(TOMBSTONE_PREFIX))) continue;
299
+ if (!(entry.isDirectory() || entry.isSymbolicLink())) continue;
300
+ const dir = path.join(base, entry.name);
301
+ if (own !== null && (dir === own || safeRealpath(dir) === own)) continue;
302
+ if (!baseTrusted || untrustedReason(dir) !== null) {
303
+ out.unknown.push(dir);
304
+ continue;
305
+ }
306
+ const state = ownerState(dir);
307
+ if (state === 'alive') {
308
+ out.kept.push(dir);
309
+ continue;
310
+ }
311
+ if (state !== 'dead' && !(state === 'missing' && Date.now() - lastActivityMs(dir) > ownerlessGraceMs)) {
312
+ out.unknown.push(dir);
313
+ continue;
314
+ }
315
+ // Claim, then re-check on the claimed path: a run that re-adopted this run id between the
316
+ // check above and the rename is alive on the tombstone and gets its root back, untouched.
317
+ const tomb = path.join(base, `${TOMBSTONE_PREFIX}${randomBytes(6).toString('hex')}`);
318
+ try {
319
+ renameSync(dir, tomb);
320
+ } catch {
321
+ continue; // already gone (removed by another sweep)
322
+ }
323
+ if (ownerState(tomb) === 'alive') {
324
+ unclaim(tomb, dir);
325
+ out.kept.push(dir);
326
+ continue;
327
+ }
328
+ await sweep(path.join(tomb, 'pids'), { graceMs: opts.graceMs });
329
+ rmSync(tomb, { recursive: true, force: true });
330
+ out.removed.push(dir);
331
+ }
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * Undo a claim: put the tombstone back at `dir`. When the re-adopting run already re-created
337
+ * `dir` (its first spawn makes `pids/` again), the rename fails; its owner record and pid
338
+ * entries are then moved into the re-created directory and the tombstone is removed, so the
339
+ * live run's root is never left ownerless.
340
+ * @param {string} tomb
341
+ * @param {string} dir
342
+ */
343
+ function unclaim(tomb, dir) {
344
+ try {
345
+ renameSync(tomb, dir);
346
+ return;
347
+ } catch {
348
+ // `dir` exists again and is not empty: merge below
349
+ }
350
+ const pids = path.join(dir, 'pids');
351
+ mkdirSync(pids, { recursive: true, mode: 0o700 });
352
+ for (const name of safeReaddir(path.join(tomb, 'pids'))) {
353
+ try {
354
+ renameSync(path.join(tomb, 'pids', name), path.join(pids, name));
355
+ } catch {
356
+ // unregistered meanwhile
357
+ }
358
+ }
359
+ try {
360
+ renameSync(path.join(tomb, OWNER_FILE), path.join(dir, OWNER_FILE));
361
+ } catch {
362
+ // no owner record on the tombstone
363
+ }
364
+ rmSync(tomb, { recursive: true, force: true });
365
+ }
366
+
367
+ /** @param {string} p @returns {string[]} entry names, or [] when unreadable. */
368
+ function safeReaddir(p) {
369
+ try {
370
+ return readdirSync(p);
371
+ } catch {
372
+ return [];
373
+ }
374
+ }
375
+
376
+ /** @param {string} p @returns {string | null} */
377
+ function safeRealpath(p) {
378
+ try {
379
+ return realpathSync(p);
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ /** @param {string} p @returns {number | null} modification time, or null when unreadable. */
386
+ function mtimeMs(p) {
387
+ try {
388
+ return lstatSync(p).mtimeMs;
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * When a run last touched this root: the newest mtime of the root itself, its `pids/` directory
396
+ * and every entry in it (each spawn writes one). An unreadable root counts as touched now.
397
+ * @param {string} dir
398
+ * @returns {number}
399
+ */
400
+ function lastActivityMs(dir) {
401
+ let newest = mtimeMs(dir);
402
+ if (newest === null) return Date.now();
403
+ const pids = path.join(dir, 'pids');
404
+ for (const p of [pids, ...safeReaddir(pids).map((name) => path.join(pids, name))]) {
405
+ const m = mtimeMs(p);
406
+ if (m !== null && m > newest) newest = m;
407
+ }
408
+ return newest;
409
+ }