@1aboveio/skills 1.0.18 → 1.0.19

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 (76) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/.codex-plugin/resources.json +138 -132
  3. package/package.json +4 -4
  4. package/skills/app-dev/airflow/SKILL.md +1 -1
  5. package/skills/app-dev/android-development/SKILL.md +1 -1
  6. package/skills/app-dev/app-debug/SKILL.md +1 -1
  7. package/skills/app-dev/better-auth/SKILL.md +1 -1
  8. package/skills/app-dev/data-catalog/SKILL.md +1 -1
  9. package/skills/app-dev/data-quality/SKILL.md +1 -1
  10. package/skills/app-dev/ios-foundation/SKILL.md +1 -1
  11. package/skills/app-dev/maestro/SKILL.md +1 -1
  12. package/skills/app-dev/monorepo/SKILL.md +1 -1
  13. package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
  14. package/skills/app-dev/prisma-setup/SKILL.md +1 -1
  15. package/skills/app-dev/pyspark/SKILL.md +1 -1
  16. package/skills/app-dev/python-backend/SKILL.md +1 -1
  17. package/skills/app-dev/shadcn/SKILL.md +1 -1
  18. package/skills/app-dev/zod-v4/SKILL.md +1 -1
  19. package/skills/cicd/cloud-build/SKILL.md +1 -1
  20. package/skills/cicd/cloud-debug/SKILL.md +1 -1
  21. package/skills/cicd/cloud-deploy/SKILL.md +1 -1
  22. package/skills/cicd/docker/SKILL.md +1 -1
  23. package/skills/cicd/github-actions/SKILL.md +1 -1
  24. package/skills/cicd/github-flow/SKILL.md +1 -1
  25. package/skills/cicd/google-cloud/SKILL.md +1 -1
  26. package/skills/cicd/mergify/SKILL.md +1 -1
  27. package/skills/cicd/podman/SKILL.md +1 -1
  28. package/skills/content/article-review/SKILL.md +1 -1
  29. package/skills/content/article-to-image/SKILL.md +1 -1
  30. package/skills/content/flowchart-svg/SKILL.md +1 -1
  31. package/skills/content/image-gen/SKILL.md +1 -1
  32. package/skills/content/mermaid/SKILL.md +1 -1
  33. package/skills/content/wechat-article-writer/SKILL.md +1 -1
  34. package/skills/content/wechat-publisher/SKILL.md +1 -1
  35. package/skills/content/wechat-search/SKILL.md +1 -1
  36. package/skills/content/xai-search/SKILL.md +1 -1
  37. package/skills/core/distill-writing/SKILL.md +1 -1
  38. package/skills/core/doc-to-md/SKILL.md +1 -1
  39. package/skills/core/editorial-review/SKILL.md +1 -1
  40. package/skills/core/house-style/SKILL.md +1 -1
  41. package/skills/core/plain-language/SKILL.md +1 -1
  42. package/skills/core/polish-cn/SKILL.md +1 -1
  43. package/skills/core/reg-parser/SKILL.md +1 -1
  44. package/skills/core/terminology/SKILL.md +1 -1
  45. package/skills/core/write-doc/SKILL.md +1 -1
  46. package/skills/core/write-report/SKILL.md +1 -1
  47. package/skills/engineering/better-review/SKILL.md +1 -1
  48. package/skills/engineering/e2e-test/SKILL.md +1 -1
  49. package/skills/engineering/engineering/SKILL.md +1 -1
  50. package/skills/engineering/engineering-runtime/SKILL.md +1 -1
  51. package/skills/engineering/engineering-runtime/coherence/workflow.json +13 -13
  52. package/skills/engineering/ensure-coverage/SKILL.md +1 -1
  53. package/skills/engineering/golden-path/SKILL.md +1 -1
  54. package/skills/engineering/harness-runtime/SKILL.md +1 -1
  55. package/skills/engineering/resolve-issues/SKILL.md +4 -2
  56. package/skills/engineering/resolve-issues/references/preflight.md +17 -3
  57. package/skills/engineering/resolve-issues/references/retention.md +162 -0
  58. package/skills/engineering/resolve-issues/references/workspaces.md +40 -10
  59. package/skills/engineering/resolve-issues/scripts/daily-cleanup.mjs +169 -0
  60. package/skills/engineering/resolve-issues/scripts/resource-formats.mjs +625 -0
  61. package/skills/engineering/resolve-issues/scripts/retention-lock.mjs +548 -0
  62. package/skills/engineering/resolve-issues/scripts/retention-plans.mjs +57 -0
  63. package/skills/engineering/resolve-issues/scripts/retention.mjs +2314 -0
  64. package/skills/engineering/resolve-release/SKILL.md +1 -1
  65. package/skills/engineering/rush-issues/SKILL.md +1 -1
  66. package/skills/engineering/rush-release/SKILL.md +1 -1
  67. package/skills/engineering/test-quality/SKILL.md +1 -1
  68. package/skills/risk-compliance/compliance-audit/SKILL.md +1 -1
  69. package/skills/risk-compliance/contract-compare/SKILL.md +1 -1
  70. package/skills/risk-compliance/contract-review/SKILL.md +1 -1
  71. package/skills/risk-compliance/doc-authenticity/SKILL.md +1 -1
  72. package/skills/risk-compliance/fraud-detection/SKILL.md +1 -1
  73. package/skills/risk-compliance/payment-analysis/SKILL.md +1 -1
  74. package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +1 -1
  75. package/skills/risk-compliance/similar-domain/SKILL.md +1 -1
  76. package/skills/risk-compliance/tld-detection/SKILL.md +1 -1
@@ -0,0 +1,2314 @@
1
+ #!/usr/bin/env node
2
+ // Bounded, explicit-ownership retention for resolve-issues run resources.
3
+ //
4
+ // retention.mjs plan --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
5
+ // retention.mjs apply --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
6
+ // retention.mjs register --repo <repo root> --run <run dir> --unit <id> --path <abs path>
7
+ // --kind generated|artifact --last-used-at <ISO> --recipe <text>
8
+ // [--format generic|pnpm-install|uv-venv|next-build|pnpm-store]
9
+ // [--input <abs canonical rebuild input> ...] [--release-owner] [--json]
10
+ // retention.mjs register-owned --repo <repo root> --run <run dir> --unit <id>
11
+ // --last-used-at <ISO> [--apply] [--json]
12
+ // retention.mjs status --repo <repo root> [--run <run dir> --unit <id>] [--json]
13
+ // retention.mjs recover --repo <repo root> [--apply] [--json]
14
+ //
15
+ // `register-owned` is the bounded batch form: it reads only the resources the unit's plan
16
+ // declares, needs each one to carry explicit retention metadata (kind, format, recipe,
17
+ // inputs), never discovers or adopts an unrecorded path, and never releases an owner. It
18
+ // previews by default and registers through the same register API with --apply.
19
+ //
20
+ // `status` is read-only and reports, per registered resource, whether it is ready to reuse
21
+ // or why a rebuild is required. `recover` is read-only by default and clears a retention
22
+ // lock only when its owner record proves that process dead on this host.
23
+ //
24
+ // This is not a whole-run purge and not a second task-plan lifecycle. The canonical
25
+ // manifest <repo>/.resolve-issues/retention.json is the only source of deletion
26
+ // candidates: a resource must have been registered explicitly, by path, against the
27
+ // unit that owns it. Existing run metadata is never a candidate, and no code here
28
+ // infers disposability from a name, an extension, or an age.
29
+ //
30
+ // A registered resource is removable only when every check holds: the owner unit is
31
+ // succeeded+merged (or the manifest carries an explicit ownerReleased approval), the
32
+ // path is inside that unit's workspace or a declared resource, no other unit or run claims
33
+ // the same tree as its workspace or resource, the tree contains only untracked/ignored
34
+ // content in its containing Git workspace, no metadata-named files, no credential- or
35
+ // secret-bearing path (environment file, registry or cloud credential, SSH identity,
36
+ // private key, keystore), no nested Git, no external symlinks, no special files, no
37
+ // required evidence, pin or checkpoint overlaps anywhere in the repository's plans, the
38
+ // recorded lastUsedAt and the newest write are old enough, and no process (or an
39
+ // unreadable process probe) uses the path. Two exceptions are narrow and typed: a
40
+ // resource registered with a proven format (pnpm-install, uv-venv, next-build,
41
+ // pnpm-store) may embed a vendored `source`/`sources`/`results`/`raw` name and may hold
42
+ // external symbolic links, because those are references that a removal unlinks without
43
+ // following. A credential, nested Git, special file, blocking run metadata name, or a
44
+ // changed rebuild input still retains the tree, and a generic resource keeps every
45
+ // strict check. A plan declaration that cannot be read is
46
+ // uncertainty about a path that must survive, so only a complete evidence scan can
47
+ // authorize a deletion. Content is hashed by streaming
48
+ // it through a fixed buffer under a finite total and per-file bound, so a real multi-GiB
49
+ // cache is snapshotted without allocating it, and a file that grows while it is read is
50
+ // refused instead of silently truncated.
51
+ //
52
+ // --apply takes <repo>/.resolve-issues/.retention-lock (never reclaimed by age; the lock
53
+ // schema and its liveness proof live in retention-lock.mjs), snapshots identity and
54
+ // content, rechecks it immediately before and after an atomic same-parent quarantine
55
+ // rename, writes receipts under <repo>/.resolve-issues/retention-receipts/, and only then
56
+ // deletes the quarantined tree. A retained row is not an I/O failure; a failure to finish
57
+ // an apply is. Before taking the lock, apply and register clear one only when its owner is
58
+ // provably dead on this host, so an interrupted apply cannot block the next one forever.
59
+ // Every other lock, receipt, and quarantined tree is left exactly as it was.
60
+ //
61
+ // Exit 0: plan/status/recover ran; apply had no I/O failure (retained rows are allowed);
62
+ // register saved; register-owned reported.
63
+ // Exit 1: apply started but could not finish a removal (read the failed receipt).
64
+ // Exit 2: usage, unreadable/contradictory manifest, held lock, or register refusal.
65
+
66
+ import { createHash } from 'node:crypto'
67
+ import { execFileSync } from 'node:child_process'
68
+ import {
69
+ closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync,
70
+ readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync,
71
+ rmSync, unlinkSync, writeFileSync,
72
+ } from 'node:fs'
73
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
74
+ import { isMainModule } from '../../engineering-runtime/scripts/main-module.mjs'
75
+ import { containsPath, liveProcessesUsing, normPath } from '../../harness-runtime/workspace-lifecycle.mjs'
76
+ import {
77
+ MAX_REBUILD_INPUTS, TYPED_FORMATS, classifyEmbeddedNames, classifyRebuildState,
78
+ inspectResourceFormat,
79
+ } from './resource-formats.mjs'
80
+ import {
81
+ RetentionLockError, acquireRetentionLock, inspectRetentionLock, recoverRetentionLock,
82
+ } from './retention-lock.mjs'
83
+ import { retentionPlanFiles } from './retention-plans.mjs'
84
+
85
+ const MANIFEST_FILE = 'retention.json'
86
+ const MANIFEST_VERSION = 1
87
+ const RECEIPT_VERSION = 1
88
+ const LOCK_NAME = '.retention-lock'
89
+ const RECEIPT_DIR = 'retention-receipts'
90
+ const QUARANTINE_PREFIX = '.retention-quarantine-'
91
+ const KINDS = new Set(['generated', 'artifact'])
92
+ // `generic` is the strict default: it carries no typed proof, so every metadata-name and
93
+ // external-link check stays in force. The rest are proven formats from resource-formats.mjs.
94
+ const FORMATS = new Set(['generic', ...TYPED_FORMATS])
95
+ const MAX_BATCH_RESOURCES = 200
96
+ const MAX_STATUS_RECEIPTS = 50
97
+ // The slots a unit or the plan root may declare a path that must survive removal in.
98
+ const EVIDENCE_KEYS = ['evidence', 'requiredEvidence', 'pins', 'checkpoints']
99
+ const DAY_MS = 24 * 60 * 60 * 1000
100
+ // Finite snapshot bounds. The total is a ceiling for one tree snapshot, not a size policy:
101
+ // a real package or build cache comfortably fits, and a bound is still enforced.
102
+ const MAX_TOTAL_HASH_BYTES = 16 * 1024 * 1024 * 1024
103
+ const MAX_FILE_HASH_BYTES = 2 * 1024 * 1024 * 1024
104
+ const HASH_CHUNK_BYTES = 2 * 1024 * 1024
105
+
106
+ // Metadata that belongs to a run record, never to a disposable resource. These are
107
+ // exact base names: a package.json or other harmless JSON is not on the list.
108
+ const METADATA_NAMES = new Set([
109
+ 'task-plan.json', 'issue-tree.json', 'taskplan.json', 'dropbox.json', 'findings.json',
110
+ 'next-actions-seen.json', 'source', 'sources', 'decisions', 'findings', 'results',
111
+ 'reviewverdicts', 'review-verdicts', 'briefs', 'profiling', 'checkpoints', 'raw', '.git',
112
+ ])
113
+
114
+ // Credential and secret-bearing configuration, matched by base name only. The list is
115
+ // deliberately finite: a guard that withheld every .json, .yml or source file would
116
+ // classify ordinary cache configuration, lockfiles and vendored sources as secrets, and a
117
+ // guard that is wrong most of the time is a guard that gets switched off. A single match
118
+ // retains the whole candidate rather than trying to lift the one entry out, because
119
+ // deciding whether a vendor's test fixture private key is synthetic means reading and
120
+ // interpreting secret content — and this module never does that. It inspects names only.
121
+ const CREDENTIAL_FILE_NAMES = new Set([
122
+ '.npmrc', '.netrc', '.yarnrc', '.yarnrc.yml', '.pypirc', '.git-credentials', '.htpasswd',
123
+ '.pgpass', '.my.cnf', 'credentials', 'credentials.db', 'keystore', 'truststore',
124
+ 'application_default_credentials.json', 'service-account.json',
125
+ ])
126
+ const CREDENTIAL_FILE_PREFIXES = ['.env', 'id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519']
127
+ const CREDENTIAL_FILE_SUFFIXES = ['.pem', '.key', '.p8', '.p12', '.pfx', '.keystore', '.jks', '.ppk']
128
+ const CREDENTIAL_DIR_NAMES = new Set(['.ssh', '.aws', '.azure', '.gnupg', '.kube', '.docker', 'gcloud'])
129
+ // Bounded reporting: the tree is retained either way, so only a readable sample is kept.
130
+ const MAX_REPORTED_CREDENTIALS = 50
131
+
132
+ function credentialName(name, isDirectory) {
133
+ const lower = String(name).toLowerCase()
134
+ if (isDirectory) {
135
+ if (CREDENTIAL_DIR_NAMES.has(lower)) return name
136
+ return lower.startsWith('.env') ? name : null
137
+ }
138
+ if (CREDENTIAL_FILE_NAMES.has(lower)) return name
139
+ if (CREDENTIAL_FILE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return name
140
+ return CREDENTIAL_FILE_SUFFIXES.some((suffix) => lower.endsWith(suffix)) ? name : null
141
+ }
142
+
143
+ const USAGE = `usage: retention.mjs plan --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
144
+ retention.mjs apply --repo <repo root> [--generated-days 7] [--artifact-days 30] [--json]
145
+ retention.mjs register --repo <repo root> --run <run dir> --unit <id> --path <abs path>
146
+ --kind generated|artifact --last-used-at <ISO> --recipe <text>
147
+ [--format generic|pnpm-install|uv-venv|next-build|pnpm-store]
148
+ [--input <abs path> ...] [--release-owner] [--json]
149
+ retention.mjs register-owned --repo <repo root> --run <run dir> --unit <id>
150
+ --last-used-at <ISO> [--apply] [--json]
151
+ retention.mjs status --repo <repo root> [--run <run dir> --unit <id>] [--json]
152
+ retention.mjs recover --repo <repo root> [--apply] [--json]
153
+
154
+ plan, status, and recover never write (recover writes only with --apply, and only to a
155
+ lock whose owner is provably dead on this host). apply writes the lock, receipts,
156
+ quarantine, and the manifest. register and register-owned are explicit owner mapping;
157
+ they never write a task-plan.`
158
+
159
+ export class RetentionError extends Error {
160
+ constructor(code, message) {
161
+ super(message)
162
+ this.code = code
163
+ }
164
+ }
165
+
166
+ class UsageError extends RetentionError {
167
+ constructor(message) { super('usage', message) }
168
+ }
169
+
170
+ const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
171
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0
172
+ const real = (p) => realpathSync.native ? realpathSync.native(p) : realpathSync(p)
173
+ const lstatOrNull = (p) => { try { return lstatSync(p) } catch (error) { if (error?.code === 'ENOENT') return null; throw error } }
174
+ const safeInt = (v) => Number.isSafeInteger(v)
175
+ const normalized = (v) => (nonEmpty(v) ? normPath(v) : v)
176
+
177
+ function nowValue(value) {
178
+ if (value === undefined) return Date.now()
179
+ if (value instanceof Date) return value.getTime()
180
+ if (typeof value === 'number' && Number.isFinite(value)) return value
181
+ const parsed = Date.parse(String(value))
182
+ if (!Number.isFinite(parsed)) throw new UsageError('the clock value is not a valid time')
183
+ return parsed
184
+ }
185
+
186
+ function timestamp(value) {
187
+ if (!nonEmpty(value)) return null
188
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/.test(value)) return null
189
+ const parsed = Date.parse(value)
190
+ if (!Number.isFinite(parsed)) return null
191
+ return parsed
192
+ }
193
+
194
+ function isoTimestamp(ms) {
195
+ return new Date(ms).toISOString()
196
+ }
197
+
198
+ function safeId(value) {
199
+ return String(value).replace(/[^A-Za-z0-9._-]/g, '_')
200
+ }
201
+
202
+ // ---- paths ----
203
+
204
+ function canonicalRepo(repo) {
205
+ if (!nonEmpty(repo) || !isAbsolute(repo)) throw new UsageError('--repo must be an absolute repository path')
206
+ let canonical
207
+ try { canonical = real(repo) } catch (error) { throw new RetentionError('repo', `cannot resolve ${repo}: ${error?.code ?? error?.message}`) }
208
+ if (!existsSync(join(canonical, '.git'))) throw new RetentionError('repo', `${canonical} is not a Git repository root`)
209
+ return canonical
210
+ }
211
+
212
+ function canonicalRoot(repo, { mustExist = false } = {}) {
213
+ const canonical = canonicalRepo(repo)
214
+ const root = join(canonical, '.resolve-issues')
215
+ const st = lstatOrNull(root)
216
+ if (!st) {
217
+ if (mustExist) throw new RetentionError('root', `canonical root ${root} does not exist`)
218
+ return { repo: canonical, root }
219
+ }
220
+ if (st.isSymbolicLink()) throw new RetentionError('symlink', `${root} is a symbolic link; the canonical root must be a real directory`)
221
+ if (!st.isDirectory()) throw new RetentionError('root', `${root} is not a directory`)
222
+ return { repo: canonical, root }
223
+ }
224
+
225
+ // A path segment from a resolved base down to a target may not be a symlink. Missing
226
+ // trailing segments are fine (the tree is created later), but an unreadable segment is
227
+ // uncertainty and is refused.
228
+ function symlinkOnPath(base, target) {
229
+ const rel = relative(base, target)
230
+ if (rel === '' ) return false
231
+ if (rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) return true
232
+ let cur = base
233
+ for (const part of rel.split(sep)) {
234
+ cur = join(cur, part)
235
+ let st
236
+ try { st = lstatSync(cur) } catch (error) {
237
+ if (error?.code === 'ENOENT') return false
238
+ return true
239
+ }
240
+ if (st.isSymbolicLink()) return true
241
+ }
242
+ return false
243
+ }
244
+
245
+ function strictInside(parent, child) {
246
+ const p = normPath(parent)
247
+ const c = normPath(child)
248
+ return Boolean(p && c && p !== c && containsPath(p, c))
249
+ }
250
+
251
+ function canonicalExistingDir(path, label) {
252
+ const abs = resolve(path)
253
+ const st = lstatOrNull(abs)
254
+ if (!st) throw new RetentionError('missing', `${label} ${abs} does not exist`)
255
+ if (st.isSymbolicLink()) throw new RetentionError('symlink', `${label} ${abs} is a symbolic link`)
256
+ if (!st.isDirectory()) throw new RetentionError('not-directory', `${label} ${abs} is not a directory`)
257
+ return real(abs)
258
+ }
259
+
260
+ // ---- bounded tree inspection ----
261
+
262
+ function allocation(st) {
263
+ if (Number.isFinite(st.blocks) && st.blocks > 0) return st.blocks * 512
264
+ return Number.isFinite(st.size) ? st.size : 0
265
+ }
266
+
267
+ // Streams one file through a fixed buffer, so nothing scales with the file size. The size
268
+ // seen by lstat is the exact expected length: a short read, one readable byte past that
269
+ // length (a growth probe), or a differing final stat (identity, size, mtime, ctime) is a
270
+ // change, and a changed file is never removed.
271
+ function hashFile(path, st, { remaining, maxFileBytes }) {
272
+ if (st.size > maxFileBytes) {
273
+ return { error: `${path} is ${st.size} bytes, beyond the ${maxFileBytes}-byte per-file snapshot bound` }
274
+ }
275
+ if (st.size > remaining) return { hash: null, read: 0, truncated: true }
276
+ let fd
277
+ try {
278
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0))
279
+ } catch (error) {
280
+ return { error: `cannot open ${path}: ${error?.code ?? error?.message}` }
281
+ }
282
+ try {
283
+ const opened = fstatSync(fd)
284
+ if (!opened.isFile() || opened.dev !== st.dev || opened.ino !== st.ino || opened.size !== st.size) {
285
+ return { error: `${path} changed while it was being snapshotted` }
286
+ }
287
+ const digest = createHash('sha256')
288
+ const buffer = Buffer.allocUnsafe(Math.min(HASH_CHUNK_BYTES, Math.max(1, st.size)))
289
+ let read = 0
290
+ while (read < st.size) {
291
+ const want = Math.min(buffer.length, st.size - read)
292
+ let got
293
+ try { got = readSync(fd, buffer, 0, want, read) } catch (error) {
294
+ return { error: `cannot read ${path}: ${error?.code ?? error?.message}` }
295
+ }
296
+ if (got === 0) return { error: `${path} shrank while it was being snapshotted` }
297
+ digest.update(buffer.subarray(0, got))
298
+ read += got
299
+ }
300
+ let growth
301
+ try { growth = readSync(fd, buffer, 0, 1, st.size) } catch (error) {
302
+ return { error: `cannot probe ${path}: ${error?.code ?? error?.message}` }
303
+ }
304
+ if (growth !== 0) return { error: `${path} grew while it was being snapshotted` }
305
+ const final = fstatSync(fd)
306
+ if (!final.isFile() || final.dev !== st.dev || final.ino !== st.ino || final.size !== st.size
307
+ || final.mtimeMs !== st.mtimeMs || final.ctimeMs !== st.ctimeMs) {
308
+ return { error: `${path} changed while it was being snapshotted` }
309
+ }
310
+ return { hash: digest.digest('hex'), read, truncated: false }
311
+ } catch (error) {
312
+ return { error: `cannot snapshot ${path}: ${error?.code ?? error?.message}` }
313
+ } finally {
314
+ try { closeSync(fd) } catch { /* already closed */ }
315
+ }
316
+ }
317
+
318
+ // Reads with lstat only (never stat/follow), sorts names for a deterministic digest,
319
+ // and stops on any bound. Any truncation or unreadable entry is a refusal, not a guess.
320
+ export function inspectTree(rootInput, options = {}) {
321
+ const maxEntries = options.maxEntries ?? 100000
322
+ const maxDepth = options.maxDepth ?? 64
323
+ const maxHashBytes = options.maxHashBytes ?? MAX_TOTAL_HASH_BYTES
324
+ const maxFileHashBytes = options.maxFileHashBytes ?? MAX_FILE_HASH_BYTES
325
+ const hashContent = options.hashContent !== false
326
+ const result = {
327
+ ok: false, reason: null, truncated: false, entries: 0, files: 0, dirs: 0, symlinks: 0,
328
+ allocatedBytes: 0, newestMtimeMs: null, rootMtimeMs: null, rootIdentity: null,
329
+ digest: null, metadataNames: [], credentialPaths: [], nestedGit: [], externalSymlinks: [],
330
+ special: [], containingWorkspace: null,
331
+ }
332
+ const input = resolve(rootInput)
333
+ let rootStat
334
+ try { rootStat = lstatSync(input) } catch (error) {
335
+ result.reason = `cannot read ${input}: ${error?.code ?? error?.message}`
336
+ return result
337
+ }
338
+ if (rootStat.isSymbolicLink()) { result.reason = `${input} is a symbolic link`; return result }
339
+ let root
340
+ try { root = real(input) } catch (error) {
341
+ result.reason = `cannot resolve ${input}: ${error?.code ?? error?.message}`
342
+ return result
343
+ }
344
+ if (!rootStat.isDirectory()) { result.reason = `${root} is not a directory`; return result }
345
+ result.rootMtimeMs = rootStat.mtimeMs
346
+ result.rootIdentity = { dev: rootStat.dev, ino: rootStat.ino }
347
+ const digest = createHash('sha256')
348
+ digest.update(`root\0${rootStat.dev}\0${rootStat.ino}\0${rootStat.mode}\0${rootStat.mtimeMs}\n`)
349
+ let hashedBytes = 0
350
+ let newest = null
351
+
352
+ const stack = [{ abs: root, rel: '', depth: 0 }]
353
+ while (stack.length) {
354
+ const item = stack.pop()
355
+ let entries
356
+ try {
357
+ entries = readdirSync(item.abs, { withFileTypes: true })
358
+ .slice()
359
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
360
+ } catch (error) {
361
+ result.reason = `cannot read ${item.abs}: ${error?.code ?? error?.message}`
362
+ return result
363
+ }
364
+ for (const entry of entries) {
365
+ if (result.entries++ >= maxEntries) { result.truncated = true; result.reason = `tree exceeds the ${maxEntries}-entry bound`; return result }
366
+ const abs = join(item.abs, entry.name)
367
+ const rel = item.rel ? `${item.rel}/${entry.name}` : entry.name
368
+ let st
369
+ try { st = lstatSync(abs) } catch (error) {
370
+ result.reason = `cannot inspect ${abs}: ${error?.code ?? error?.message}`
371
+ return result
372
+ }
373
+ result.allocatedBytes += allocation(st)
374
+ digest.update(`${rel}\0${st.dev}\0${st.ino}\0${st.mode}\0${st.size}\0${st.mtimeMs}\n`)
375
+ if (result.credentialPaths.length < MAX_REPORTED_CREDENTIALS && credentialName(entry.name, st.isDirectory())) {
376
+ result.credentialPaths.push(rel)
377
+ }
378
+ if (METADATA_NAMES.has(entry.name)) result.metadataNames.push(rel)
379
+ if (st.isSymbolicLink()) {
380
+ result.symlinks += 1
381
+ if (st.mtimeMs > (newest ?? 0)) newest = st.mtimeMs
382
+ let link = null
383
+ try { link = readlinkSync(abs) } catch (error) {
384
+ result.reason = `cannot read symbolic link ${abs}: ${error?.code ?? error?.message}`
385
+ return result
386
+ }
387
+ digest.update(`link\0${rel}\0${link}\n`)
388
+ const candidate = isAbsolute(link) ? resolve(link) : resolve(dirname(abs), link)
389
+ let outside = !(containsPath(root, candidate) || normPath(candidate) === root)
390
+ if (!outside) {
391
+ try {
392
+ const target = real(abs)
393
+ outside = !(containsPath(root, target) || normPath(target) === root)
394
+ } catch { /* a dangling link is unlinked, never followed */ }
395
+ }
396
+ if (outside) result.externalSymlinks.push({ path: abs, target: link })
397
+ continue
398
+ }
399
+ if (st.isDirectory()) {
400
+ result.dirs += 1
401
+ if (item.depth + 1 > maxDepth) { result.truncated = true; result.reason = `tree exceeds the ${maxDepth}-level depth bound`; return result }
402
+ if (entry.name === '.git') result.nestedGit.push(rel)
403
+ const bare = existsSync(join(abs, 'HEAD')) && existsSync(join(abs, 'objects')) && existsSync(join(abs, 'refs'))
404
+ if (bare) result.nestedGit.push(rel)
405
+ stack.push({ abs, rel, depth: item.depth + 1 })
406
+ continue
407
+ }
408
+ if (entry.name === '.git') result.nestedGit.push(rel)
409
+ if (!st.isFile()) { result.special.push(rel); continue }
410
+ result.files += 1
411
+ if (st.mtimeMs > (newest ?? 0)) newest = st.mtimeMs
412
+ if (hashContent) {
413
+ const hashed = hashFile(abs, st, { remaining: maxHashBytes - hashedBytes, maxFileBytes: maxFileHashBytes })
414
+ if (hashed.error) { result.reason = hashed.error; return result }
415
+ if (hashed.truncated) {
416
+ result.truncated = true
417
+ result.reason = `content snapshot exceeds the ${maxHashBytes}-byte bound`
418
+ return result
419
+ }
420
+ hashedBytes += hashed.read
421
+ digest.update(`content\0${rel}\0${hashed.hash}\n`)
422
+ }
423
+ }
424
+ }
425
+ result.digest = digest.digest('hex')
426
+ result.newestMtimeMs = newest ?? rootStat.mtimeMs
427
+ result.ok = true
428
+ return result
429
+ }
430
+
431
+ function snapshotOf(inspection) {
432
+ return {
433
+ digest: inspection.digest,
434
+ entries: inspection.entries,
435
+ files: inspection.files,
436
+ allocatedBytes: inspection.allocatedBytes,
437
+ newestMtimeMs: inspection.newestMtimeMs,
438
+ root: inspection.rootIdentity,
439
+ }
440
+ }
441
+
442
+ function sameSnapshot(a, b) {
443
+ if (!isObject(a) || !isObject(b)) return false
444
+ return a.digest === b.digest && a.entries === b.entries && a.files === b.files
445
+ && a.allocatedBytes === b.allocatedBytes && a.newestMtimeMs === b.newestMtimeMs
446
+ && a.root?.dev === b.root?.dev && a.root?.ino === b.root?.ino
447
+ }
448
+
449
+ // ---- Git proof ----
450
+
451
+ function git(dir, args) {
452
+ try {
453
+ const out = execFileSync('git', ['-C', dir, ...args], {
454
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000, maxBuffer: 8 * 1024 * 1024,
455
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' },
456
+ })
457
+ return { ok: true, out: String(out) }
458
+ } catch (error) {
459
+ return { ok: false, err: String(error?.stderr ?? error?.message ?? '') }
460
+ }
461
+ }
462
+
463
+ // The resource must live inside a real Git workspace and contain no tracked path.
464
+ // If no containing workspace can be found, trackedness cannot be proved, so the
465
+ // resource is refused rather than assumed disposable.
466
+ function trackedProof(target) {
467
+ const top = git(target, ['rev-parse', '--show-toplevel'])
468
+ if (!top.ok || !nonEmpty(top.out.trim())) {
469
+ return { ok: false, reason: `${target} is not inside a readable Git workspace, so untracked content cannot be proved` }
470
+ }
471
+ let toplevel
472
+ try { toplevel = real(top.out.trim()) } catch (error) {
473
+ return { ok: false, reason: `cannot resolve the containing Git workspace of ${target}: ${error?.code ?? error?.message}` }
474
+ }
475
+ let targetReal
476
+ try { targetReal = real(target) } catch (error) {
477
+ return { ok: false, reason: `cannot resolve ${target}: ${error?.code ?? error?.message}` }
478
+ }
479
+ if (!containsPath(toplevel, targetReal)) return { ok: false, reason: `${target} is outside its containing Git workspace ${toplevel}` }
480
+ const rel = relative(toplevel, targetReal)
481
+ const tracked = git(toplevel, ['ls-files', '-z', '--', rel])
482
+ if (!tracked.ok) return { ok: false, reason: `cannot list tracked paths under ${target}` }
483
+ const count = tracked.out.split('\0').filter(Boolean).length
484
+ if (count) return { ok: false, reason: `${count} tracked path(s) live inside ${target}; tracked source is never disposable` }
485
+ return { ok: true, toplevel, relative: rel }
486
+ }
487
+
488
+ // ---- manifest ----
489
+
490
+ function manifestPath(root) {
491
+ return join(root, MANIFEST_FILE)
492
+ }
493
+
494
+ export function readManifest(root) {
495
+ const path = manifestPath(root)
496
+ const st = lstatOrNull(path)
497
+ if (!st) return { version: MANIFEST_VERSION, entries: [] }
498
+ if (st.isSymbolicLink()) throw new RetentionError('symlink', `${path} is a symbolic link`)
499
+ if (!st.isFile()) throw new RetentionError('manifest', `${path} is not a regular file`)
500
+ if (st.size > 8 * 1024 * 1024) throw new RetentionError('manifest', `${path} exceeds the 8 MiB manifest bound`)
501
+ let parsed
502
+ try { parsed = JSON.parse(readFileSync(path, 'utf8')) } catch (error) {
503
+ throw new RetentionError('manifest', `${path} is not readable JSON: ${error.message}`)
504
+ }
505
+ if (!isObject(parsed) || parsed.version !== MANIFEST_VERSION || !Array.isArray(parsed.entries)) {
506
+ throw new RetentionError('manifest', `${path} must be {"version": 1, "entries": [...]}`)
507
+ }
508
+ return parsed
509
+ }
510
+
511
+ function writeManifest(root, manifest) {
512
+ const path = manifestPath(root)
513
+ const st = lstatOrNull(path)
514
+ if (st?.isSymbolicLink()) throw new RetentionError('symlink', `${path} is a symbolic link`)
515
+ const temporary = join(root, `.${MANIFEST_FILE}.${process.pid}.${Date.now()}.tmp`)
516
+ try {
517
+ writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
518
+ renameSync(temporary, path)
519
+ } catch (error) {
520
+ try { unlinkSync(temporary) } catch { /* absent */ }
521
+ throw new RetentionError('io', `cannot write ${path}: ${error?.code ?? error?.message}`)
522
+ }
523
+ return path
524
+ }
525
+
526
+ // ---- locking ----
527
+
528
+ // The lock contract (strong owner record, liveness proof, atomic same-parent quarantine)
529
+ // lives in retention-lock.mjs. What retention adds is one policy: before taking the lock it
530
+ // may clear a lock whose recorded owner is provably dead on this host, because an
531
+ // interrupted apply leaves that lock behind and the lock is never reclaimed by age. Live,
532
+ // foreign, legacy, unreadable and ambiguous locks are kept, as is every receipt and
533
+ // quarantined tree the interrupted apply wrote: releasing a dead lock never claims that
534
+ // the apply finished.
535
+ function recoverDeadLock(root) {
536
+ let inspection
537
+ try {
538
+ inspection = inspectRetentionLock(root)
539
+ } catch (error) {
540
+ if (error instanceof RetentionLockError) throw new RetentionError(error.code === 'usage' ? 'usage' : 'lock', error.message)
541
+ throw error
542
+ }
543
+ if (!inspection.present || !inspection.recoverable) return null
544
+ let recovery
545
+ try {
546
+ recovery = recoverRetentionLock(root, { apply: true })
547
+ } catch (error) {
548
+ if (error instanceof RetentionLockError) throw new RetentionError('lock', error.message)
549
+ throw error
550
+ }
551
+ return {
552
+ status: recovery.status,
553
+ action: recovery.action,
554
+ reason: recovery.reason,
555
+ cleared: recovery.cleared,
556
+ owner: inspection.owner,
557
+ pendingQuarantines: recovery.quarantines,
558
+ }
559
+ }
560
+
561
+ function acquireLock(root) {
562
+ const dir = join(root, LOCK_NAME)
563
+ if (symlinkOnPath(root, dir)) throw new RetentionError('symlink', `${dir} passes through a symbolic link`)
564
+ const recovery = recoverDeadLock(root)
565
+ let lock
566
+ try {
567
+ lock = acquireRetentionLock(root)
568
+ } catch (error) {
569
+ if (error instanceof RetentionLockError) {
570
+ const code = error.code === 'locked' ? 'locked' : error.code === 'usage' ? 'usage' : 'io'
571
+ throw new RetentionError(code, error.message)
572
+ }
573
+ throw error
574
+ }
575
+ return {
576
+ path: lock.path,
577
+ owner: lock.owner,
578
+ recovery,
579
+ release() {
580
+ try { return lock.release() } catch { return { released: false, reason: 'release-failed' } }
581
+ },
582
+ }
583
+ }
584
+
585
+ // ---- plans, owners, evidence ----
586
+
587
+ function readPlanFile(path) {
588
+ const st = lstatOrNull(path)
589
+ if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) return null
590
+ try {
591
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
592
+ return isObject(parsed) ? parsed : null
593
+ } catch { return null }
594
+ }
595
+
596
+ function unitOfPlan(plan, unitId) {
597
+ const rows = [...(Array.isArray(plan?.units) ? plan.units : []), ...(Array.isArray(plan?.issues) ? plan.issues : [])]
598
+ return rows.find((row) => isObject(row) && String(row.id) === String(unitId)) ?? null
599
+ }
600
+
601
+ function deliveryMerged(unit) {
602
+ const delivery = unit?.delivery
603
+ if (delivery === 'merged') return true
604
+ if (isObject(delivery)) return delivery.outcome === 'merged' || delivery.state === 'merged' || delivery.status === 'merged'
605
+ return false
606
+ }
607
+
608
+ function unitCompleted(unit) {
609
+ return unit?.status === 'succeeded' && deliveryMerged(unit)
610
+ }
611
+
612
+ function declaredResources(unit, run) {
613
+ const raw = unit?.lifecycle?.resources
614
+ if (!Array.isArray(raw)) return []
615
+ return raw.filter((r) => isObject(r) && nonEmpty(r.path))
616
+ .map((r) => (isAbsolute(r.path) ? resolve(r.path) : resolve(run, r.path)))
617
+ }
618
+
619
+ // Resolve as far as the filesystem allows. A required-evidence path that has not been
620
+ // written yet still names a tree: its deepest existing ancestor is canonicalized and the
621
+ // missing tail is appended lexically, so a link spelling cannot hide an overlap with the
622
+ // target it really points into. Non-absence failures are uncertainty (null).
623
+ function canonicalResolve(p) {
624
+ let current = normPath(resolve(p))
625
+ const missing = []
626
+ for (;;) {
627
+ try {
628
+ const base = normPath(real(current))
629
+ return missing.length ? normPath(join(base, ...missing.slice().reverse())) : base
630
+ } catch (error) {
631
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null
632
+ }
633
+ const parent = dirname(current)
634
+ if (parent === current) return normPath(resolve(p))
635
+ missing.push(basename(current))
636
+ current = parent
637
+ }
638
+ }
639
+
640
+ // A path may be spelled lexically or reached through a link. Both forms are compared, so
641
+ // no claim can be dodged by an alternate spelling. A path that cannot be resolved for a
642
+ // reason other than absence is uncertainty, and uncertainty never authorizes a deletion.
643
+ function pathForms(p) {
644
+ const lexical = normPath(resolve(p))
645
+ if (!lexical) return null
646
+ const forms = [lexical]
647
+ const canonical = canonicalResolve(lexical)
648
+ if (canonical === null) return null
649
+ if (canonical && canonical !== lexical) forms.push(canonical)
650
+ return forms
651
+ }
652
+
653
+ function formsOverlap(left, right) {
654
+ return left.some((a) => right.some((b) => containsPath(a, b) || containsPath(b, a)))
655
+ }
656
+
657
+ // Another unit or run claiming the same tree as its own workspace or resource makes this
658
+ // target ambiguous ownership, not a candidate. Only real claims count: a run directory or a
659
+ // shared resource container is not a claim unless a plan declares it as one.
660
+ function claimConflict(target, claims, owner) {
661
+ if (!isObject(claims) || !claims.complete) {
662
+ return { status: 'retained', reason: 'the bounded workspace/resource claim scan did not finish, so overlapping ownership cannot be ruled out' }
663
+ }
664
+ const forms = pathForms(target)
665
+ if (!forms) return { status: 'retained', reason: `the ownership forms of ${target} cannot be resolved` }
666
+ for (const claim of claims.list) {
667
+ if (claim.run === owner.run && claim.unit === String(owner.unit)) continue
668
+ if (formsOverlap(forms, claim.forms)) {
669
+ return {
670
+ status: 'refused',
671
+ reason: `${claim.label} declares ${claim.kind} ${claim.path}, which overlaps ${target}; a tree another unit or run claims is never removed`,
672
+ }
673
+ }
674
+ }
675
+ return null
676
+ }
677
+
678
+ // Every path a plan declares as required evidence (or a required checkpoint or pin), plus
679
+ // every workspace and declared resource that names an owner. One bounded walk of the
680
+ // canonical root produces both, and each proof keeps its own completeness: an unreadable
681
+ // evidence declaration says nothing about who owns a tree, and claiming otherwise would
682
+ // report the wrong uncertainty. Either one incomplete never authorizes a deletion.
683
+ function planIndex(root, bounds = {}) {
684
+ const locations = retentionPlanFiles(root, { maxPlans: bounds.maxPlans ?? 200, maxEntries: bounds.maxEvidenceEntries ?? 20000 })
685
+ const maxClaims = bounds.maxClaims ?? 20000
686
+ const evidence = []
687
+ const claims = []
688
+ let evidenceComplete = locations.complete
689
+ let claimsComplete = locations.complete
690
+ const collectEvidence = (raw, run) => {
691
+ if (raw === undefined) return
692
+ if (!Array.isArray(raw)) { evidenceComplete = false; return }
693
+ for (const entry of raw) {
694
+ if (typeof entry === 'string') {
695
+ if (!nonEmpty(entry)) { evidenceComplete = false; continue }
696
+ evidence.push({ path: resolve(run, entry), run })
697
+ continue
698
+ }
699
+ // An entry that names no readable path, or whose `required` is not a boolean, is a
700
+ // declaration nobody can resolve. Dropping it silently is how a required path became
701
+ // an authorized deletion, so a malformed entry makes the whole scan incomplete.
702
+ // Only an entry that says `required: false` is knowingly optional, and it protects
703
+ // nothing: the caller chose that.
704
+ if (!isObject(entry) || !nonEmpty(entry.path)
705
+ || (entry.required !== undefined && typeof entry.required !== 'boolean')) {
706
+ evidenceComplete = false
707
+ continue
708
+ }
709
+ if (entry.required === false) continue
710
+ evidence.push({ path: resolve(run, entry.path), run })
711
+ }
712
+ }
713
+ // A unit and the plan itself declare evidence, pins and checkpoints in the same four
714
+ // slots, plus lifecycle.cleanup.evidence. Every slot is read the same way, so a path is
715
+ // protected wherever the run recorded it, including at the root of the plan.
716
+ const collectDeclarations = (scope, run) => {
717
+ if (!isObject(scope)) return
718
+ for (const key of EVIDENCE_KEYS) {
719
+ collectEvidence(scope[key], run)
720
+ collectEvidence(scope.lifecycle?.[key], run)
721
+ }
722
+ collectEvidence(scope.lifecycle?.cleanup?.evidence, run)
723
+ }
724
+ const collectClaim = (run, unitId, raw, kind) => {
725
+ if (raw === undefined) return
726
+ if (!nonEmpty(raw)) { claimsComplete = false; return }
727
+ const lexical = isAbsolute(raw) ? resolve(raw) : resolve(run, raw)
728
+ const forms = pathForms(lexical)
729
+ if (!forms || claims.length >= maxClaims) { claimsComplete = false; return }
730
+ claims.push({ run, unit: String(unitId), kind, path: lexical, forms, label: `unit ${unitId} of ${run}` })
731
+ }
732
+ const readPlan = (dir) => {
733
+ const abs = join(dir, 'task-plan.json')
734
+ if (!existsSync(abs)) return
735
+ const plan = readPlanFile(abs)
736
+ if (!plan) { evidenceComplete = false; claimsComplete = false; return }
737
+ if (plan.units !== undefined && !Array.isArray(plan.units)) { evidenceComplete = false; claimsComplete = false; return }
738
+ if (plan.issues !== undefined && !Array.isArray(plan.issues)) { evidenceComplete = false; claimsComplete = false; return }
739
+ collectDeclarations(plan, dir)
740
+ for (const unit of [...(Array.isArray(plan.units) ? plan.units : []), ...(Array.isArray(plan.issues) ? plan.issues : [])]) {
741
+ if (!isObject(unit)) { evidenceComplete = false; claimsComplete = false; continue }
742
+ collectDeclarations(unit, dir)
743
+ if (!nonEmpty(unit.id) && !Number.isSafeInteger(unit.id)) { claimsComplete = false; continue }
744
+ // A null workspace explicitly means no allocation in legacy pending units.
745
+ // A non-null malformed path remains uncertainty and blocks the scan.
746
+ if (unit.workspace !== null) collectClaim(dir, unit.id, unit.workspace, 'workspace')
747
+ const resources = unit.lifecycle?.resources === undefined ? unit.resources : unit.lifecycle.resources
748
+ if (resources !== undefined && !Array.isArray(resources)) { claimsComplete = false; continue }
749
+ for (const resource of Array.isArray(resources) ? resources : []) {
750
+ if (!isObject(resource)) { claimsComplete = false; continue }
751
+ collectClaim(dir, unit.id, resource.path, 'resource')
752
+ }
753
+ }
754
+ }
755
+ for (const path of locations.paths) readPlan(dirname(path))
756
+ return { evidence: { paths: evidence, complete: evidenceComplete }, claims: { list: claims, complete: claimsComplete } }
757
+ }
758
+
759
+ function requiredEvidenceOverlap(target, index) {
760
+ if (!index.complete) return `the bounded evidence scan did not finish, so required-evidence overlap cannot be ruled out`
761
+ const targetForms = pathForms(target)
762
+ if (!targetForms) return `the canonical forms of ${target} cannot be resolved, so required-evidence overlap cannot be ruled out`
763
+ for (const evidence of index.paths) {
764
+ const evidenceForms = pathForms(evidence.path)
765
+ if (!evidenceForms) return `required evidence ${evidence.path} (declared by ${evidence.run}) cannot be resolved, so overlap with ${target} cannot be ruled out`
766
+ if (formsOverlap(targetForms, evidenceForms)) {
767
+ return `required evidence ${evidence.path} (declared by ${evidence.run}) overlaps ${target}`
768
+ }
769
+ }
770
+ return null
771
+ }
772
+
773
+ function runDirs(root, bounds = {}) {
774
+ const limit = bounds.maxRuns ?? 100
775
+ const runs = []
776
+ const add = (path) => {
777
+ if (runs.length >= limit) return
778
+ const st = lstatOrNull(path)
779
+ if (!st || st.isSymbolicLink() || !st.isDirectory()) return
780
+ runs.push(path)
781
+ }
782
+ if (!existsSync(root)) return runs
783
+ const rush = join(root, 'rush')
784
+ if (existsSync(rush) && !lstatSync(rush).isSymbolicLink()) {
785
+ for (const entry of readdirSync(rush, { withFileTypes: true })) {
786
+ if (entry.isDirectory() && !entry.isSymbolicLink()) add(join(rush, entry.name))
787
+ }
788
+ }
789
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
790
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue
791
+ if (!entry.name.endsWith('-workspaces')) continue
792
+ const legacy = join(root, entry.name)
793
+ let children = []
794
+ try {
795
+ children = readdirSync(legacy, { withFileTypes: true })
796
+ .filter((child) => child.isDirectory() && !child.isSymbolicLink())
797
+ .map((child) => join(legacy, child.name))
798
+ } catch { /* an unreadable legacy container is inventory-only */ }
799
+ if (children.length) for (const child of children) add(child)
800
+ else add(legacy)
801
+ }
802
+ return runs
803
+ }
804
+
805
+ function inventory(root, bounds) {
806
+ const runs = []
807
+ let complete = true
808
+ if (!existsSync(root)) return { runs, complete, totalBytes: 0 }
809
+ for (const run of runDirs(root, bounds)) {
810
+ const scan = inspectTree(run, {
811
+ maxEntries: bounds.maxInventoryEntries ?? 20000,
812
+ maxDepth: bounds.maxDepth ?? 64,
813
+ hashContent: false,
814
+ })
815
+ if (!scan.ok) complete = false
816
+ runs.push({
817
+ run, bytes: scan.allocatedBytes, entries: scan.entries,
818
+ truncated: scan.truncated, readable: scan.ok, reason: scan.reason,
819
+ })
820
+ }
821
+ runs.sort((a, b) => b.bytes - a.bytes)
822
+ const resources = []
823
+ const maxResources = bounds.maxInventoryResources ?? 100
824
+ const addResource = (path, ownerRun) => {
825
+ if (resources.length >= maxResources) return
826
+ const st = lstatOrNull(path)
827
+ if (!st || st.isSymbolicLink() || !st.isDirectory()) return
828
+ const scan = inspectTree(path, {
829
+ maxEntries: bounds.maxInventoryEntries ?? 20000,
830
+ maxDepth: bounds.maxDepth ?? 64,
831
+ hashContent: false,
832
+ })
833
+ if (!scan.ok) complete = false
834
+ resources.push({ path, run: ownerRun, bytes: scan.allocatedBytes, entries: scan.entries, truncated: scan.truncated })
835
+ }
836
+ const addTwoLevels = (base, depth, ownerRun) => {
837
+ let entries
838
+ try { entries = readdirSync(base, { withFileTypes: true }) } catch { return }
839
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
840
+ if (resources.length >= maxResources) return
841
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue
842
+ const abs = join(base, entry.name)
843
+ if (depth > 1) addTwoLevels(abs, depth - 1, ownerRun)
844
+ else addResource(abs, ownerRun)
845
+ }
846
+ }
847
+ for (const run of runs) {
848
+ if (resources.length >= maxResources) break
849
+ addTwoLevels(join(run.run, 'resources'), 2, run.run)
850
+ let reviewUnits = []
851
+ try { reviewUnits = readdirSync(join(run.run, 'reviews'), { withFileTypes: true }) } catch { /* none */ }
852
+ for (const unit of reviewUnits) {
853
+ if (!unit.isDirectory() || unit.isSymbolicLink()) continue
854
+ addResource(join(run.run, 'reviews', unit.name, 'clone'), run.run)
855
+ }
856
+ }
857
+ resources.sort((a, b) => b.bytes - a.bytes)
858
+ return {
859
+ runs, resources, complete,
860
+ totalBytes: runs.reduce((sum, r) => sum + r.bytes, 0),
861
+ totalResourceBytes: resources.reduce((sum, r) => sum + r.bytes, 0),
862
+ }
863
+ }
864
+
865
+ // ---- entry validation and ownership ----
866
+
867
+ // A recorded rebuild input proof is a small, bounded record: the canonical file's name and
868
+ // absolute path, the content hash that was proven, and the filesystem identity it was
869
+ // bound to. Anything else is a claim no apply pass can recheck, so it is refused by shape
870
+ // rather than trusted.
871
+ function inputProofProblem(proof, index) {
872
+ const where = `inputProofs[${index}]`
873
+ if (!isObject(proof)) return `${where} is not an object`
874
+ if (!nonEmpty(proof.name)) return `${where}.name is missing`
875
+ if (!nonEmpty(proof.path) || !isAbsolute(proof.path)) return `${where}.path must be absolute`
876
+ if (!nonEmpty(proof.hash) || !/^[0-9a-f]{64}$/.test(proof.hash)) return `${where}.hash must be a sha256 hex digest`
877
+ const identity = proof.identity
878
+ if (!isObject(identity) || !safeInt(identity.dev) || !safeInt(identity.ino) || !safeInt(identity.size)) {
879
+ return `${where}.identity must carry integer dev, ino and size`
880
+ }
881
+ return null
882
+ }
883
+
884
+ function parseEntry(entry, { forStatus = false } = {}) {
885
+ const problems = []
886
+ if (!isObject(entry)) return { problems: ['entry is not an object'] }
887
+ if (!nonEmpty(entry.id)) problems.push('id is missing')
888
+ if (!nonEmpty(entry.path) || !isAbsolute(entry.path)) problems.push('path must be absolute')
889
+ if (!KINDS.has(entry.kind)) problems.push(`kind ${String(entry.kind)} is not generated|artifact`)
890
+ if (!isObject(entry.owner) || !nonEmpty(entry.owner.run) || !isAbsolute(entry.owner.run) || !nonEmpty(entry.owner.unit)) problems.push('owner must be {run: absolute path, unit: id}')
891
+ if (timestamp(entry.lastUsedAt) === null) problems.push('lastUsedAt must be an ISO timestamp')
892
+ if (typeof entry.disposable !== 'boolean' || (!forStatus && entry.disposable !== true)) problems.push('disposable must be true; the manifest cannot be edited into disposability')
893
+ if (typeof entry.pinned !== 'boolean' || (!forStatus && entry.pinned !== false)) problems.push('pinned must be false; pinned resources are never candidates')
894
+ if (entry.kind === 'generated' && !nonEmpty(entry.recipe)) problems.push('generated resources need a nonempty reconstruction recipe')
895
+ if (entry.kind === 'artifact' && typeof entry.recipe !== 'string') problems.push('artifact recipe must be a string (it may be empty)')
896
+ if (!isObject(entry.identity) || !safeInt(entry.identity.dev) || !safeInt(entry.identity.ino)) problems.push('identity must carry integer dev and ino')
897
+ if (!isObject(entry.authorization) || entry.authorization.source !== 'explicit-registration' || timestamp(entry.authorization.at) === null) problems.push('authorization must be an explicit-registration record with an ISO at')
898
+ if (entry.ownerReleased !== undefined && typeof entry.ownerReleased !== 'boolean') problems.push('ownerReleased must be a boolean when present')
899
+ if (entry.cleanupState !== undefined && !['removed', 'failed'].includes(entry.cleanupState)) problems.push(`cleanupState ${String(entry.cleanupState)} is not removed|failed`)
900
+ // A format is a claim about proof, so an unreadable or unsupported name is refused
901
+ // rather than treated as `generic`: the two carry different checks.
902
+ if (entry.format !== undefined && (!nonEmpty(entry.format) || !FORMATS.has(entry.format))) {
903
+ problems.push(`format ${String(entry.format)} is not ${[...FORMATS].join('|')}`)
904
+ }
905
+ const format = entry.format ?? 'generic'
906
+ if (entry.inputProofs !== undefined) {
907
+ if (!Array.isArray(entry.inputProofs)) problems.push('inputProofs must be an array')
908
+ else if (entry.inputProofs.length === 0) problems.push('inputProofs must record at least one proven rebuild input')
909
+ else if (entry.inputProofs.length > MAX_REBUILD_INPUTS) problems.push(`inputProofs carries more than ${MAX_REBUILD_INPUTS} proofs`)
910
+ else {
911
+ for (const [index, proof] of entry.inputProofs.entries()) {
912
+ const problem = inputProofProblem(proof, index)
913
+ if (problem) { problems.push(problem); break }
914
+ }
915
+ }
916
+ }
917
+ // The stored format and the stored proof must agree. A typed format without retained
918
+ // inputs cannot be rebuilt, and a generic resource carrying them is a proof nobody made.
919
+ if (format !== 'generic' && !Array.isArray(entry.inputProofs)) problems.push(`${format} needs recorded rebuild input proofs`)
920
+ if (format === 'generic' && entry.inputProofs !== undefined) problems.push('a generic resource carries no typed rebuild input proofs')
921
+ return { problems, format }
922
+ }
923
+
924
+ function ownershipProblem({ root, run, unit, unitId, target, plan }) {
925
+ let runReal, targetReal, rootReal
926
+ try { rootReal = real(root) } catch { return `cannot resolve ${root}` }
927
+ try { runReal = real(run) } catch { return `run ${run} does not exist or cannot be resolved` }
928
+ try { targetReal = real(target) } catch { return `target ${target} does not exist or cannot be resolved` }
929
+ if (!strictInside(rootReal, runReal)) return `run ${run} is not inside the canonical root ${root}`
930
+ if (!strictInside(rootReal, targetReal)) return `target ${target} is not inside the canonical root ${root}`
931
+ if (symlinkOnPath(rootReal, resolve(run))) return `run ${run} passes through a symbolic link`
932
+ if (symlinkOnPath(rootReal, resolve(target))) return `target ${target} passes through a symbolic link`
933
+ if (targetReal === rootReal || targetReal === runReal) return `target ${target} is the root or the run itself`
934
+ const repoRoot = plan?.repoRoot
935
+ if (nonEmpty(repoRoot)) {
936
+ let recorded
937
+ try { recorded = real(repoRoot) } catch { return `the plan's repoRoot ${repoRoot} cannot be resolved` }
938
+ if (recorded !== real(dirname(rootReal))) return `the plan's repoRoot ${repoRoot} is not this repository`
939
+ }
940
+ const expectedWorkspace = join(runReal, 'units', String(unitId))
941
+ let expectedReal
942
+ try { expectedReal = real(expectedWorkspace) } catch { return `the unit workspace ${expectedWorkspace} does not exist or cannot be resolved` }
943
+ if (!nonEmpty(unit.workspace)) return `unit ${unitId} records no workspace`
944
+ let recordedWorkspace
945
+ try { recordedWorkspace = real(unit.workspace) } catch { return `unit ${unitId} workspace ${unit.workspace} cannot be resolved` }
946
+ if (recordedWorkspace !== expectedReal) return `unit ${unitId} workspace ${unit.workspace} is not ${expectedWorkspace}; the plan and the disk disagree`
947
+ if (targetReal === recordedWorkspace) return `target ${target} is the unit workspace itself`
948
+ const resourcesRoot = join(runReal, 'resources', String(unitId))
949
+ const reviewRoot = join(runReal, 'reviews', String(unitId))
950
+ if (targetReal === resourcesRoot || targetReal === reviewRoot) return `target ${target} is a resource/review container, not a leaf resource`
951
+ const declared = declaredResources(unit, runReal).map((p) => {
952
+ try { return real(p) } catch { return resolve(p) }
953
+ })
954
+ if (METADATA_NAMES.has(basename(targetReal))) return `target ${target} is named like run metadata, which is never disposable`
955
+ if (containsPath(recordedWorkspace, targetReal) && targetReal !== recordedWorkspace) return null
956
+ const owners = declared.filter((p) => containsPath(p, targetReal))
957
+ if (owners.length) {
958
+ // A declared resource may be a leaf or a parent of the registered path. Refuse a
959
+ // declared path that itself escapes the run's resource roots.
960
+ if (owners.some((p) => containsPath(resourcesRoot, p) || containsPath(reviewRoot, p) || containsPath(recordedWorkspace, p))) return null
961
+ return `declared resource(s) ${owners.join(', ')} for unit ${unitId} are outside the run's resource roots`
962
+ }
963
+ return `target ${target} is neither inside unit ${unitId}'s workspace nor a declared resource in its task-plan`
964
+ }
965
+
966
+ // A typed format that was proven from the same tree inspection is the only authority that
967
+ // can relax two generic guards. It never relaxes the rest: credentials, nested Git,
968
+ // special files and blocking run metadata are engine policy, not format policy, and a
969
+ // resource whose rebuild inputs changed keeps every guard.
970
+ function formatVerdict(entry, target, inspection) {
971
+ const format = nonEmpty(entry.format) ? entry.format : 'generic'
972
+ if (format === 'generic') {
973
+ return {
974
+ format, typed: false, ok: true, state: 'generic', reason: null,
975
+ blockingMetadata: [], allowReferenceSymlinks: false,
976
+ }
977
+ }
978
+ // The recorded format and its rebuild inputs are re-proved here: the markers must still
979
+ // describe that format, and every recorded input must still be the proven file. A changed
980
+ // input means the artifact is no longer reproducible, so it is retained for rebuild.
981
+ const state = classifyRebuildState({ format, path: target, inputProofs: entry.inputProofs, inspection })
982
+ const names = classifyEmbeddedNames({ format, names: inspection.metadataNames })
983
+ return {
984
+ format,
985
+ typed: true,
986
+ ok: state.ok === true,
987
+ state: state.state,
988
+ reason: state.reason,
989
+ blockingMetadata: names.blocking,
990
+ allowReferenceSymlinks: state.ok === true,
991
+ markers: state.markers,
992
+ inputCheck: state.inputCheck,
993
+ }
994
+ }
995
+
996
+ function preservationProblem(inspection, typed = null) {
997
+ const proven = typed?.ok === true && typed?.format !== undefined && typed.format !== 'generic'
998
+ const referenceLinks = proven && typed.allowReferenceSymlinks
999
+ if (!referenceLinks && inspection.externalSymlinks.length) {
1000
+ return `symbolic link(s) point outside the target: ${inspection.externalSymlinks.slice(0, 3).map((s) => `${s.path} -> ${s.target}`).join(', ')}`
1001
+ }
1002
+ if (inspection.special.length) return `special file(s) cannot be snapshotted or removed safely: ${inspection.special.slice(0, 3).join(', ')}`
1003
+ if (inspection.nestedGit.length) return `nested Git repository inside the target: ${inspection.nestedGit.slice(0, 3).join(', ')}`
1004
+ if (inspection.credentialPaths.length) {
1005
+ return `credential- or secret-bearing path(s) inside the target are never disposable, so the whole candidate is retained: ${inspection.credentialPaths.slice(0, 3).join(', ')}`
1006
+ }
1007
+ // A proven format may embed the vendored names installed dependencies really contain;
1008
+ // every other metadata name still blocks, because a generated tree never owns a plan, a
1009
+ // finding record, a checkpoint or a Git repository.
1010
+ const blocking = proven ? typed.blockingMetadata : inspection.metadataNames
1011
+ if (blocking.length) return `metadata-named path(s) inside the target are never disposable: ${blocking.slice(0, 3).join(', ')}`
1012
+ return null
1013
+ }
1014
+
1015
+ function treeSafety(target, inspection, typed = null) {
1016
+ if (!inspection.ok) return inspection.reason
1017
+ if (inspection.truncated) return inspection.reason ?? 'tree inspection hit a bound'
1018
+ const problem = preservationProblem(inspection, typed)
1019
+ if (problem) return problem
1020
+ const proof = trackedProof(target)
1021
+ if (!proof.ok) return proof.reason
1022
+ inspection.containingWorkspace = proof.toplevel
1023
+ return null
1024
+ }
1025
+
1026
+ function duplicateIds(entries) {
1027
+ const counts = new Map()
1028
+ for (const entry of entries) {
1029
+ if (!nonEmpty(entry?.id)) continue
1030
+ counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1)
1031
+ }
1032
+ return new Set([...counts].filter(([, count]) => count > 1).map(([id]) => id))
1033
+ }
1034
+
1035
+ function overlappingPairs(entries) {
1036
+ const realPaths = entries.map((entry) => {
1037
+ try { return { id: entry?.id, path: normPath(real(entry.path)) } } catch { return { id: entry?.id, path: normPath(resolve(String(entry?.path ?? ''))) } }
1038
+ })
1039
+ const overlaps = new Map()
1040
+ for (let i = 0; i < realPaths.length; i++) {
1041
+ for (let j = i + 1; j < realPaths.length; j++) {
1042
+ const a = realPaths[i]
1043
+ const b = realPaths[j]
1044
+ if (!a.path || !b.path) continue
1045
+ if (containsPath(a.path, b.path) || containsPath(b.path, a.path)) {
1046
+ if (!overlaps.has(i)) overlaps.set(i, [])
1047
+ if (!overlaps.has(j)) overlaps.set(j, [])
1048
+ overlaps.get(i).push(b.id ?? b.path)
1049
+ overlaps.get(j).push(a.id ?? a.path)
1050
+ }
1051
+ }
1052
+ }
1053
+ return overlaps
1054
+ }
1055
+
1056
+ function receiptPathFor(root, id, atMs) {
1057
+ return join(root, RECEIPT_DIR, `${safeId(id)}-${isoTimestamp(atMs).replace(/[:.]/g, '-')}.json`)
1058
+ }
1059
+
1060
+ function receiptCandidates(root, id) {
1061
+ const dir = join(root, RECEIPT_DIR)
1062
+ let names
1063
+ try { names = readdirSync(dir) } catch { return [] }
1064
+ return names.filter((name) => name.startsWith(`${safeId(id)}-`) && name.endsWith('.json'))
1065
+ .sort().reverse().map((name) => join(dir, name))
1066
+ }
1067
+
1068
+ function verifiedCompleteReceipt(root, entry) {
1069
+ for (const path of receiptCandidates(root, entry.id)) {
1070
+ const st = lstatOrNull(path)
1071
+ if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) continue
1072
+ let saved
1073
+ try { saved = JSON.parse(readFileSync(path, 'utf8')) } catch { continue }
1074
+ if (saved?.version !== RECEIPT_VERSION || saved.kind !== 'retention-receipt') continue
1075
+ if (saved.id !== entry.id || normPath(saved.path) !== normPath(entry.path)) continue
1076
+ if (normPath(saved.root) !== normPath(root)) continue
1077
+ if (saved.resourceKind !== entry.kind || normalized(saved.owner?.run) !== normalized(entry.owner?.run) || saved.unit !== String(entry.owner?.unit)) continue
1078
+ if (saved.status !== 'complete' || saved.removed !== true) continue
1079
+ if (saved.identity?.dev !== entry.identity?.dev || saved.identity?.ino !== entry.identity?.ino) continue
1080
+ const now = lstatOrNull(entry.path)
1081
+ if (now) continue
1082
+ return { ok: true, path }
1083
+ }
1084
+ return { ok: false, reason: 'no complete receipt binds this entry and its absence' }
1085
+ }
1086
+
1087
+ function writeReceipt(root, payload, targetPath = null) {
1088
+ const dir = join(root, RECEIPT_DIR)
1089
+ if (symlinkOnPath(root, dir)) throw new RetentionError('symlink', `${dir} passes through a symbolic link`)
1090
+ const path = targetPath ?? receiptPathFor(root, payload.id, payload.atMs)
1091
+ const unique = targetPath ? path : (() => {
1092
+ let candidate = path
1093
+ for (let i = 1; existsSync(candidate) && i < 100; i++) candidate = path.replace(/\.json$/, `-${i}.json`)
1094
+ return candidate
1095
+ })()
1096
+ try {
1097
+ mkdirSync(dir, { recursive: true })
1098
+ const temporary = join(dir, `.${safeId(payload.id)}.${process.pid}.${Date.now()}.tmp`)
1099
+ writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
1100
+ renameSync(temporary, unique)
1101
+ } catch (error) {
1102
+ throw new RetentionError('io', `cannot write receipt ${unique}: ${error?.code ?? error?.message}`)
1103
+ }
1104
+ return unique
1105
+ }
1106
+
1107
+ // ---- evaluation ----
1108
+
1109
+ // A plan revision binds one apply step to the plan that evaluation read, so a concurrent
1110
+ // admission cannot be smuggled past the snapshot.
1111
+ function planFingerprint(path, plan) {
1112
+ const st = lstatOrNull(path)
1113
+ const identity = st ? `${st.dev}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}` : 'absent'
1114
+ return `${identity}:${createHash('sha256').update(JSON.stringify(plan)).digest('hex')}`
1115
+ }
1116
+
1117
+ function loadOwner(ctx, entry) {
1118
+ const runReal = real(entry.owner.run)
1119
+ if (!strictInside(ctx.rootReal, runReal)) {
1120
+ return { ok: false, status: 'refused', reason: `owner run ${entry.owner.run} is not inside ${ctx.root}` }
1121
+ }
1122
+ const planPath = join(runReal, 'task-plan.json')
1123
+ const plan = readPlanFile(planPath)
1124
+ if (!plan) {
1125
+ return { ok: false, status: 'retained', reason: `owner task-plan ${planPath} is missing or unreadable; ownership cannot be proved` }
1126
+ }
1127
+ const unit = unitOfPlan(plan, entry.owner.unit)
1128
+ if (!unit) {
1129
+ return { ok: false, status: 'retained', reason: `unit ${entry.owner.unit} is not in ${planPath}; ownership cannot be proved` }
1130
+ }
1131
+ return { ok: true, runReal, planPath, plan, unit, revision: planFingerprint(planPath, plan) }
1132
+ }
1133
+
1134
+ // One authority gate, used by evaluation and re-run from a fresh plan read immediately
1135
+ // before the quarantine rename. A reused gate is the only way the two can agree.
1136
+ function ownerGate({ ctx, entry, target, loaded, claims, evidence }) {
1137
+ const ownership = ownershipProblem({ root: ctx.root, run: loaded.runReal, unit: loaded.unit, unitId: entry.owner.unit, target, plan: loaded.plan })
1138
+ if (ownership) return { status: 'refused', reason: ownership }
1139
+ if (loaded.unit.worker != null && loaded.unit.worker !== '') {
1140
+ return { status: 'retained', reason: `unit ${entry.owner.unit} records active worker ${loaded.unit.worker}; an old resource is still in use` }
1141
+ }
1142
+ const completed = unitCompleted(loaded.unit)
1143
+ if (!completed && entry.ownerReleased !== true) {
1144
+ return { status: 'retained', reason: `unit ${entry.owner.unit} is not succeeded+merged and the manifest has no explicit ownerReleased approval; age never infers a release` }
1145
+ }
1146
+ const conflict = claimConflict(target, claims, { run: loaded.runReal, unit: entry.owner.unit })
1147
+ if (conflict) return conflict
1148
+ const overlap = requiredEvidenceOverlap(target, evidence)
1149
+ if (overlap) return { status: 'retained', reason: overlap }
1150
+ return null
1151
+ }
1152
+
1153
+ function evaluateEntry(context) {
1154
+ const { entry, index, manifest, ctx, mode } = context
1155
+ const checks = []
1156
+ const add = (name, ok, detail) => { checks.push({ name, ok, detail }); return ok }
1157
+ const row = {
1158
+ id: entry?.id ?? null,
1159
+ path: entry?.path ?? null,
1160
+ kind: entry?.kind ?? null,
1161
+ owner: entry?.owner ?? null,
1162
+ lastUsedAt: entry?.lastUsedAt ?? null,
1163
+ bytes: 0,
1164
+ newestMtime: null,
1165
+ status: 'refused',
1166
+ reason: null,
1167
+ checks,
1168
+ }
1169
+ const parsed = parseEntry(entry)
1170
+ if (!parsed.problems.length) add('shape', true, 'manifest entry is complete')
1171
+ else {
1172
+ row.reason = parsed.problems.join('; ')
1173
+ row.status = 'refused'
1174
+ return row
1175
+ }
1176
+ row.format = parsed.format
1177
+ if (entry.cleanupState === 'removed') {
1178
+ const receipt = verifiedCompleteReceipt(ctx.root, entry)
1179
+ const present = lstatOrNull(entry.path)
1180
+ if (present) {
1181
+ row.status = 'refused'
1182
+ row.reason = `manifest says removed, but ${entry.path} exists; the manifest and the disk disagree`
1183
+ return row
1184
+ }
1185
+ if (!receipt.ok) {
1186
+ row.status = 'retained'
1187
+ row.reason = `manifest says removed and the path is absent, but ${receipt.reason}; rerun cannot claim success`
1188
+ return row
1189
+ }
1190
+ row.status = 'already-removed'
1191
+ row.reason = 'path absent and a complete receipt binds the removal'
1192
+ row.receipt = receipt.path
1193
+ return row
1194
+ }
1195
+ if (!lstatOrNull(entry.path)) {
1196
+ const receipt = verifiedCompleteReceipt(ctx.root, entry)
1197
+ if (receipt.ok) {
1198
+ row.status = 'already-removed'
1199
+ row.reason = 'path absent and a complete receipt binds the removal; the manifest marker is missing'
1200
+ row.receipt = receipt.path
1201
+ return row
1202
+ }
1203
+ }
1204
+ if (ctx.duplicateIds.has(entry.id)) {
1205
+ row.status = 'refused'
1206
+ row.reason = `id ${entry.id} appears more than once in the manifest; duplicate ownership is never resolved by guessing`
1207
+ return row
1208
+ }
1209
+ const overlap = ctx.overlaps.get(index)
1210
+ if (overlap?.length) {
1211
+ row.status = 'refused'
1212
+ row.reason = `overlaps another registered resource (${overlap.join(', ')}); overlapping registrations never count or delete twice`
1213
+ return row
1214
+ }
1215
+ add('overlap', true, 'no registered path contains or is contained by another')
1216
+
1217
+ const target = resolve(entry.path)
1218
+ let loaded
1219
+ try { loaded = loadOwner(ctx, entry) } catch (error) {
1220
+ row.status = 'retained'
1221
+ row.reason = `owner run cannot be resolved: ${error?.message ?? error}`
1222
+ return row
1223
+ }
1224
+ if (!loaded.ok) { row.status = loaded.status; row.reason = loaded.reason; return row }
1225
+ const gate = ownerGate({ ctx, entry, target, loaded, claims: ctx.claims, evidence: ctx.evidence })
1226
+ if (gate) { row.status = gate.status; row.reason = gate.reason; return row }
1227
+ add('ownership', true, `${entry.owner.unit} owns ${target}`)
1228
+ add('owner', true, unitCompleted(loaded.unit) ? 'succeeded and delivered' : 'owner released explicitly in the manifest')
1229
+ add('claims', true, 'no other unit or run claims the target as its workspace or resource')
1230
+ add('evidence', true, 'no declared required evidence overlaps the target')
1231
+ row.ownerRevision = loaded.revision
1232
+
1233
+ const inspection = inspectTree(target, { ...ctx.bounds, hashContent: mode === 'apply' })
1234
+ row.bytes = inspection.allocatedBytes
1235
+ row.newestMtime = inspection.newestMtimeMs === null ? null : isoTimestamp(inspection.newestMtimeMs)
1236
+ // A typed resource is re-proved before any policy question is asked about deletion: a
1237
+ // format whose markers or rebuild inputs no longer match is not a deletion candidate, it
1238
+ // is work to rebuild.
1239
+ const typed = formatVerdict(entry, target, inspection)
1240
+ if (typed.typed && !typed.ok) {
1241
+ row.status = 'retained'
1242
+ row.rebuildRequired = true
1243
+ row.formatState = typed.state
1244
+ row.reason = `${typed.format} resource at ${target} is not proven reusable: ${typed.reason}`
1245
+ return row
1246
+ }
1247
+ if (typed.typed) add('format', true, `${typed.format} markers and rebuild inputs still match the registration`)
1248
+ const unsafe = treeSafety(target, inspection, typed)
1249
+ if (unsafe) { row.status = 'retained'; row.reason = unsafe; return row }
1250
+ add('tree', true, `${inspection.files} file(s), ${inspection.allocatedBytes} allocated byte(s), only untracked content`)
1251
+
1252
+ const lastUsedMs = timestamp(entry.lastUsedAt)
1253
+ if (lastUsedMs === null) { row.status = 'refused'; row.reason = 'lastUsedAt is not a valid ISO timestamp'; return row }
1254
+ if (lastUsedMs > ctx.nowMs) { row.status = 'refused'; row.reason = `lastUsedAt ${entry.lastUsedAt} is in the future`; return row }
1255
+ if (inspection.newestMtimeMs === null) { row.status = 'retained'; row.reason = 'the newest write cannot be read'; return row }
1256
+ if (inspection.newestMtimeMs > ctx.nowMs + 1000) { row.status = 'refused'; row.reason = `the newest write inside ${target} is in the future`; return row }
1257
+ const days = entry.kind === 'generated' ? ctx.generatedDays : ctx.artifactDays
1258
+ const threshold = days * DAY_MS
1259
+ const age = ctx.nowMs - lastUsedMs
1260
+ const writeAge = ctx.nowMs - inspection.newestMtimeMs
1261
+ row.ageDays = age / DAY_MS
1262
+ if (age < threshold) {
1263
+ row.status = 'retained'
1264
+ row.reason = `lastUsedAt is ${(age / DAY_MS).toFixed(3)} day(s) old; ${entry.kind} retention is ${days} day(s)`
1265
+ return row
1266
+ }
1267
+ if (writeAge < threshold) {
1268
+ row.status = 'retained'
1269
+ row.reason = `a file inside ${target} was written ${(writeAge / DAY_MS).toFixed(3)} day(s) ago; the newest write can only delay deletion`
1270
+ return row
1271
+ }
1272
+ add('age', true, `lastUsedAt ${(age / DAY_MS).toFixed(3)} day(s), newest write ${(writeAge / DAY_MS).toFixed(3)} day(s), threshold ${days}`)
1273
+
1274
+ const recorded = lstatOrNull(target)
1275
+ if (!recorded) { row.status = 'retained'; row.reason = `${target} disappeared during evaluation`; return row }
1276
+ if (recorded.dev !== entry.identity.dev || recorded.ino !== entry.identity.ino) {
1277
+ row.status = 'refused'
1278
+ row.reason = `${target} dev/ino ${recorded.dev}/${recorded.ino} is not the registered ${entry.identity.dev}/${entry.identity.ino}; a replacement tree is never removed`
1279
+ return row
1280
+ }
1281
+ add('identity', true, `${entry.identity.dev}/${entry.identity.ino}`)
1282
+
1283
+ const uncertain = []
1284
+ const live = ctx.probe([target], { onUncertain: (detail) => uncertain.push(detail) })
1285
+ if (live === 'unreadable') {
1286
+ row.status = 'retained'
1287
+ row.reason = `process use of ${target} cannot be determined; uncertainty never authorizes deletion`
1288
+ return row
1289
+ }
1290
+ if (live === 'unavailable') {
1291
+ row.status = 'retained'
1292
+ row.reason = `no process-use probe is available, so ${target} cannot be shown idle`
1293
+ return row
1294
+ }
1295
+ if (!Array.isArray(live)) {
1296
+ row.status = 'retained'
1297
+ row.reason = `process use of ${target} returned ${String(live)}; only a proven empty scan authorizes deletion`
1298
+ return row
1299
+ }
1300
+ if (live.length) {
1301
+ row.status = 'retained'
1302
+ row.reason = `${live.length} process(es) are using ${target} (${live.slice(0, 3).join(', ')})`
1303
+ return row
1304
+ }
1305
+ add('process', true, 'no process was found using the target')
1306
+
1307
+ row.status = 'ready'
1308
+ row.reason = `ready: ${entry.kind} past its ${days}-day threshold; every safety check passed`
1309
+ row.snapshot = snapshotOf(inspection)
1310
+ row.inspection = inspection
1311
+ return row
1312
+ }
1313
+
1314
+ function baseContext({ repo, generatedDays, artifactDays, now, probe, bounds }, manifest) {
1315
+ const { root } = canonicalRoot(repo)
1316
+ const rootReal = existsSync(root) ? real(root) : root
1317
+ const index = planIndex(rootReal, bounds)
1318
+ return {
1319
+ repo: canonicalRepo(repo),
1320
+ root,
1321
+ rootReal,
1322
+ generatedDays,
1323
+ artifactDays,
1324
+ nowMs: now,
1325
+ probe,
1326
+ bounds,
1327
+ manifest,
1328
+ evidence: index.evidence,
1329
+ claims: index.claims,
1330
+ overlaps: overlappingPairs(manifest.entries),
1331
+ duplicateIds: duplicateIds(manifest.entries),
1332
+ }
1333
+ }
1334
+
1335
+ function assertDays(value, flag) {
1336
+ if (!Number.isSafeInteger(value) || value < 0) throw new UsageError(`${flag} must be a nonnegative integer`)
1337
+ return value
1338
+ }
1339
+
1340
+ function normalizeBounds(bounds = {}) {
1341
+ return {
1342
+ maxEntries: bounds.maxEntries ?? 100000,
1343
+ maxDepth: bounds.maxDepth ?? 64,
1344
+ maxHashBytes: bounds.maxHashBytes ?? MAX_TOTAL_HASH_BYTES,
1345
+ maxFileHashBytes: bounds.maxFileHashBytes ?? MAX_FILE_HASH_BYTES,
1346
+ maxClaims: bounds.maxClaims ?? 20000,
1347
+ maxPlanDepth: bounds.maxPlanDepth ?? 6,
1348
+ maxPlans: bounds.maxPlans ?? 200,
1349
+ maxEvidenceEntries: bounds.maxEvidenceEntries ?? 20000,
1350
+ maxRuns: bounds.maxRuns ?? 100,
1351
+ maxInventoryEntries: bounds.maxInventoryEntries ?? 20000,
1352
+ }
1353
+ }
1354
+
1355
+ function summarize(rows) {
1356
+ return {
1357
+ total: rows.length,
1358
+ ready: rows.filter((r) => r.status === 'ready').length,
1359
+ retained: rows.filter((r) => r.status === 'retained').length,
1360
+ refused: rows.filter((r) => r.status === 'refused').length,
1361
+ alreadyRemoved: rows.filter((r) => r.status === 'already-removed').length,
1362
+ candidatesBytes: rows.filter((r) => r.status === 'ready').reduce((sum, r) => sum + (r.bytes ?? 0), 0),
1363
+ }
1364
+ }
1365
+
1366
+ function publicRow(row) {
1367
+ const { inspection, snapshot, ownerRevision, ...rest } = row
1368
+ return rest
1369
+ }
1370
+
1371
+ // ---- plan ----
1372
+
1373
+ export function planRetention({
1374
+ repo, generatedDays = 7, artifactDays = 30, now, probe = liveProcessesUsing, bounds = {},
1375
+ } = {}) {
1376
+ assertDays(generatedDays, '--generated-days')
1377
+ assertDays(artifactDays, '--artifact-days')
1378
+ const nowMs = nowValue(now)
1379
+ const { root } = canonicalRoot(repo)
1380
+ const manifest = existsSync(root) ? readManifest(root) : { version: MANIFEST_VERSION, entries: [] }
1381
+ const ctx = baseContext({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds: normalizeBounds(bounds) }, manifest)
1382
+ const rows = manifest.entries.map((entry, index) => {
1383
+ try { return evaluateEntry({ entry, index, manifest, ctx, mode: 'plan' }) } catch (error) {
1384
+ return { id: entry?.id ?? null, path: entry?.path ?? null, status: 'refused', reason: `entry evaluation failed: ${error?.message ?? error}`, checks: [] }
1385
+ }
1386
+ })
1387
+ rows.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0))
1388
+ const view = rows.map(publicRow)
1389
+ return {
1390
+ version: 1, command: 'plan', ok: true, applied: false,
1391
+ repo: ctx.repo, root: ctx.root,
1392
+ generatedDays, artifactDays, now: isoTimestamp(nowMs),
1393
+ entries: view,
1394
+ targets: view.filter((r) => r.status === 'ready'),
1395
+ summary: summarize(rows),
1396
+ inventory: inventory(ctx.rootReal, ctx.bounds),
1397
+ evidenceScanComplete: ctx.evidence.complete,
1398
+ errors: [],
1399
+ }
1400
+ }
1401
+
1402
+ // ---- apply ----
1403
+
1404
+ function quarantineDestination(target, id, atMs) {
1405
+ return join(dirname(target), `${QUARANTINE_PREFIX}${safeId(id)}-${isoTimestamp(atMs).replace(/[:.]/g, '-')}`)
1406
+ }
1407
+
1408
+ function applyOne(context, row) {
1409
+ const { entry, ctx } = context
1410
+ const target = resolve(entry.path)
1411
+ const atMs = ctx.nowMs
1412
+ const receiptBase = {
1413
+ version: RECEIPT_VERSION, kind: 'retention-receipt', id: entry.id, path: normPath(target),
1414
+ root: normPath(ctx.root), repo: normPath(ctx.repo), run: normPath(entry.owner.run),
1415
+ unit: entry.owner.unit, owner: entry.owner, resourceKind: entry.kind, recipe: entry.recipe ?? '',
1416
+ format: entry.format ?? 'generic', inputProofs: entry.inputProofs ?? null,
1417
+ identity: entry.identity, atMs, at: isoTimestamp(atMs),
1418
+ status: 'applying', removed: false, quarantine: null,
1419
+ }
1420
+ // A fresh inode check immediately before anything else.
1421
+ const before = lstatOrNull(target)
1422
+ if (!before) return { action: 'retained', reason: `${target} disappeared before apply` }
1423
+ if (before.dev !== entry.identity.dev || before.ino !== entry.identity.ino) {
1424
+ return { action: 'retained', reason: `${target} dev/ino changed before apply; a replacement tree is never removed` }
1425
+ }
1426
+ const first = inspectTree(target, { ...ctx.bounds, hashContent: true })
1427
+ const firstTyped = formatVerdict(entry, target, first)
1428
+ if (firstTyped.typed && !firstTyped.ok) {
1429
+ return { action: 'retained', reason: `${firstTyped.format} resource is not proven reusable immediately before apply: ${firstTyped.reason}`, rebuildRequired: true }
1430
+ }
1431
+ const unsafe = treeSafety(target, first, firstTyped)
1432
+ if (unsafe) return { action: 'retained', reason: unsafe }
1433
+ if (!sameSnapshot(row.snapshot, snapshotOf(first))) {
1434
+ return { action: 'retained', reason: `${target} changed between plan evaluation and apply` }
1435
+ }
1436
+
1437
+ // Test seam only: a real filesystem writer outside this function is the production
1438
+ // equivalent. It runs before the final snapshot recheck, never after it.
1439
+ ctx.hooks.beforeQuarantine?.({ entry: publicRow(row), target })
1440
+
1441
+ const second = inspectTree(target, { ...ctx.bounds, hashContent: true })
1442
+ const secondTyped = formatVerdict(entry, target, second)
1443
+ if (secondTyped.typed && !secondTyped.ok) {
1444
+ return { action: 'retained', reason: `${secondTyped.format} resource is not proven reusable immediately before quarantine: ${secondTyped.reason}`, rebuildRequired: true }
1445
+ }
1446
+ const secondUnsafe = treeSafety(target, second, secondTyped)
1447
+ if (secondUnsafe) return { action: 'retained', reason: secondUnsafe }
1448
+ if (!sameSnapshot(snapshotOf(first), snapshotOf(second))) {
1449
+ return { action: 'retained', reason: `${target} changed immediately before quarantine; nothing was moved` }
1450
+ }
1451
+ // Owner authority is re-read here, not trusted from evaluation: a worker can be admitted,
1452
+ // a delivery withdrawn, or another unit or run can declare this tree between the first
1453
+ // snapshot and the rename. The plan revision binds this step to the plan evaluation read.
1454
+ let fresh
1455
+ try { fresh = loadOwner(ctx, entry) } catch (error) {
1456
+ return { action: 'retained', reason: `owner run cannot be resolved immediately before quarantine: ${error?.message ?? error}` }
1457
+ }
1458
+ if (!fresh.ok) return { action: fresh.status, reason: fresh.reason }
1459
+ const index = planIndex(ctx.rootReal, ctx.bounds)
1460
+ const gate = ownerGate({ ctx, entry, target, loaded: fresh, claims: index.claims, evidence: index.evidence })
1461
+ if (gate) return { action: gate.status, reason: gate.reason }
1462
+ if (row.ownerRevision === undefined || fresh.revision !== row.ownerRevision) {
1463
+ return { action: 'retained', reason: `owner task-plan ${fresh.planPath} changed during apply; ${target} was retained, restart with a fresh plan` }
1464
+ }
1465
+ const stillThere = lstatOrNull(target)
1466
+ if (!stillThere || stillThere.dev !== entry.identity.dev || stillThere.ino !== entry.identity.ino) {
1467
+ return { action: 'retained', reason: `${target} identity changed immediately before quarantine` }
1468
+ }
1469
+ const freshManifest = readManifest(ctx.root)
1470
+ const freshEntry = freshManifest.entries.find((item) => item?.id === entry.id)
1471
+ if (!freshEntry || JSON.stringify(freshEntry) !== JSON.stringify(entry)) {
1472
+ return { action: 'retained', reason: `the manifest entry for ${entry.id} changed during apply; restart with a fresh plan` }
1473
+ }
1474
+ const live = ctx.probe([target], { onUncertain: () => {} })
1475
+ if (live === 'unreadable' || live === 'unavailable' || !Array.isArray(live)) {
1476
+ return { action: 'retained', reason: `process use of ${target} became uncertain immediately before apply` }
1477
+ }
1478
+ if (live.length) return { action: 'retained', reason: `${live.length} process(es) are using ${target} immediately before apply` }
1479
+
1480
+ let receipt
1481
+ try {
1482
+ receipt = writeReceipt(ctx.root, receiptBase)
1483
+ } catch (error) {
1484
+ return { action: 'failed', reason: error.message, ioFailure: true }
1485
+ }
1486
+ const quarantine = quarantineDestination(target, entry.id, atMs)
1487
+ if (existsSync(quarantine)) {
1488
+ const failed = { ...receiptBase, receipt, status: 'failed', reason: `quarantine destination ${quarantine} already exists` }
1489
+ try { writeReceipt(ctx.root, failed, receipt) } catch { /* already an I/O failure */ }
1490
+ return { action: 'failed', reason: failed.reason, receipt, ioFailure: true }
1491
+ }
1492
+ try {
1493
+ renameSync(target, quarantine)
1494
+ } catch (error) {
1495
+ const failed = { ...receiptBase, receipt, status: 'failed', reason: `quarantine rename failed: ${error?.code ?? error?.message}` }
1496
+ try { writeReceipt(ctx.root, failed, receipt) } catch { /* receipt failure is reported below */ }
1497
+ return { action: 'failed', reason: failed.reason, receipt, ioFailure: true }
1498
+ }
1499
+ const moved = inspectTree(quarantine, { ...ctx.bounds, hashContent: true })
1500
+ if (!moved.ok || !sameSnapshot(snapshotOf(second), snapshotOf(moved)) || moved.rootIdentity?.dev !== entry.identity.dev || moved.rootIdentity?.ino !== entry.identity.ino) {
1501
+ let restored = true
1502
+ try { renameSync(quarantine, target) } catch { restored = false }
1503
+ const failed = {
1504
+ ...receiptBase, receipt, status: 'failed', quarantine,
1505
+ reason: restored
1506
+ ? 'content changed at the quarantine destination; the tree was restored and retained'
1507
+ : 'content changed at the quarantine destination and restoring the original name failed; inspect the quarantine path',
1508
+ }
1509
+ try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
1510
+ return { action: restored ? 'retained' : 'failed', reason: failed.reason, receipt, quarantine, ioFailure: !restored }
1511
+ }
1512
+ try {
1513
+ ctx.hooks.beforeDelete?.({ entry: publicRow(row), quarantine })
1514
+ // fs.rm removes a symbolic link itself and never walks through it, so an external
1515
+ // reference inside a proven format is unlinked while its destination is untouched.
1516
+ rmSync(quarantine, { recursive: true, force: true })
1517
+ } catch (error) {
1518
+ const failed = {
1519
+ ...receiptBase, receipt, status: 'failed', quarantine,
1520
+ reason: `removal of the quarantined tree failed: ${error?.code ?? error?.message}; the original path is gone`,
1521
+ }
1522
+ try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
1523
+ return { action: 'failed', reason: failed.reason, receipt, quarantine, ioFailure: true }
1524
+ }
1525
+ if (existsSync(target) || existsSync(quarantine)) {
1526
+ const failed = { ...receiptBase, receipt, status: 'failed', quarantine, reason: 'a removal postcondition is not established; the original path or quarantine still exists' }
1527
+ try { writeReceipt(ctx.root, failed, receipt) } catch { /* reported as IO failure */ }
1528
+ return { action: 'failed', reason: failed.reason, receipt, quarantine, ioFailure: true }
1529
+ }
1530
+ const complete = {
1531
+ ...receiptBase, receipt, status: 'complete', quarantine,
1532
+ removedAt: isoTimestamp(atMs), removed: true,
1533
+ snapshot: {
1534
+ digest: second.digest, entries: second.entries, files: second.files,
1535
+ allocatedBytes: second.allocatedBytes, newestMtimeMs: second.newestMtimeMs,
1536
+ },
1537
+ }
1538
+ try {
1539
+ writeReceipt(ctx.root, complete, receipt)
1540
+ } catch (error) {
1541
+ return { action: 'failed', reason: `the tree was removed but the complete receipt cannot be written: ${error.message}`, receipt, quarantine, ioFailure: true }
1542
+ }
1543
+ const updated = readManifest(ctx.root)
1544
+ const next = updated.entries.map((item) => item?.id === entry.id
1545
+ ? { ...item, cleanupState: 'removed', removedAt: isoTimestamp(atMs), receipt: normPath(receipt) }
1546
+ : item)
1547
+ try {
1548
+ writeManifest(ctx.root, { ...updated, version: MANIFEST_VERSION, entries: next })
1549
+ } catch (error) {
1550
+ return { action: 'removed', reason: `removed and receipted, but the manifest marker could not be written: ${error.message}`, receipt, quarantine, ioFailure: true }
1551
+ }
1552
+ return { action: 'removed', reason: `quarantined, verified, and removed; receipt ${receipt}`, receipt, quarantine }
1553
+ }
1554
+
1555
+ export function applyRetention({
1556
+ repo, generatedDays = 7, artifactDays = 30, now, probe = liveProcessesUsing, bounds = {}, hooks = {},
1557
+ } = {}) {
1558
+ assertDays(generatedDays, '--generated-days')
1559
+ assertDays(artifactDays, '--artifact-days')
1560
+ const nowMs = nowValue(now)
1561
+ const { root } = canonicalRoot(repo)
1562
+ if (!existsSync(root)) return { ...planRetention({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds }), command: 'apply', applied: false, actions: [], ioFailures: [] }
1563
+ const lock = acquireLock(root)
1564
+ try {
1565
+ const manifest = readManifest(root)
1566
+ const ctx = baseContext({ repo, generatedDays, artifactDays, now: nowMs, probe, bounds: normalizeBounds(bounds) }, manifest)
1567
+ ctx.hooks = hooks
1568
+ const rows = manifest.entries.map((entry, index) => {
1569
+ try { return { entry, row: evaluateEntry({ entry, index, manifest, ctx, mode: 'apply' }) } } catch (error) {
1570
+ return { entry, row: { id: entry?.id ?? null, path: entry?.path ?? null, status: 'refused', reason: `entry evaluation failed: ${error?.message ?? error}`, checks: [] } }
1571
+ }
1572
+ })
1573
+ rows.sort((a, b) => (b.row.bytes ?? 0) - (a.row.bytes ?? 0))
1574
+ const ioFailures = []
1575
+ const results = []
1576
+ for (const { entry, row } of rows) {
1577
+ if (row.status === 'already-removed') {
1578
+ const latest = readManifest(root)
1579
+ const saved = latest.entries.find((item) => item?.id === entry.id)
1580
+ if (saved && saved.cleanupState !== 'removed') {
1581
+ try {
1582
+ writeManifest(root, {
1583
+ ...latest,
1584
+ entries: latest.entries.map((item) => (item?.id === entry.id
1585
+ ? { ...item, cleanupState: 'removed', removedAt: item.removedAt ?? isoTimestamp(nowMs), receipt: row.receipt }
1586
+ : item)),
1587
+ })
1588
+ } catch (error) {
1589
+ ioFailures.push({ id: entry.id, path: entry.path, reason: error.message })
1590
+ }
1591
+ }
1592
+ results.push({ ...publicRow(row), action: 'already-removed' })
1593
+ continue
1594
+ }
1595
+ if (row.status !== 'ready') {
1596
+ results.push({ ...publicRow(row), action: row.status })
1597
+ continue
1598
+ }
1599
+ let outcome
1600
+ try { outcome = applyOne({ entry, ctx, row }, row) } catch (error) {
1601
+ outcome = { action: 'failed', reason: `apply failed safely: ${error?.message ?? error}`, ioFailure: true }
1602
+ }
1603
+ if (outcome.action === 'failed' && outcome.receipt) {
1604
+ try {
1605
+ const latest = readManifest(root)
1606
+ writeManifest(root, {
1607
+ ...latest,
1608
+ entries: latest.entries.map((item) => (item?.id === entry.id
1609
+ ? { ...item, cleanupState: 'failed', receipt: outcome.receipt, failedAt: isoTimestamp(nowMs) }
1610
+ : item)),
1611
+ })
1612
+ } catch (error) { ioFailures.push({ id: entry.id, path: entry.path, reason: error.message }) }
1613
+ }
1614
+ if (outcome.ioFailure) ioFailures.push({ id: entry.id, path: entry.path, reason: outcome.reason })
1615
+ results.push({ ...publicRow(row), ...outcome })
1616
+ }
1617
+ return {
1618
+ version: 1, command: 'apply', ok: ioFailures.length === 0, applied: true,
1619
+ repo: ctx.repo, root: ctx.root,
1620
+ generatedDays, artifactDays, now: isoTimestamp(nowMs),
1621
+ evidenceScanComplete: ctx.evidence.complete,
1622
+ lockRecovery: lock.recovery,
1623
+ entries: results,
1624
+ summary: summarize(results.map((r) => ({ ...r, status: r.action === 'removed' ? 'removed' : r.status }))),
1625
+ ioFailures,
1626
+ errors: ioFailures.map((f) => f.reason),
1627
+ }
1628
+ } finally {
1629
+ lock.release()
1630
+ }
1631
+ }
1632
+
1633
+ // ---- register ----
1634
+
1635
+ function assertTimestamp(value, flag) {
1636
+ if (timestamp(value) === null) throw new UsageError(`${flag} must be an ISO-8601 UTC timestamp such as 2026-10-01T00:00:00.000Z`)
1637
+ return new Date(timestamp(value)).toISOString()
1638
+ }
1639
+
1640
+ // The format and its explicit rebuild inputs are caller choices, so a wrong combination is
1641
+ // a usage error and never a guess. A generic resource records no input proof at all.
1642
+ function formatInputs(format, inputs) {
1643
+ if (!nonEmpty(format)) throw new UsageError('--format must be a format name')
1644
+ if (!FORMATS.has(format)) throw new UsageError(`--format must be ${[...FORMATS].join('|')}`)
1645
+ const list = (Array.isArray(inputs) ? inputs : [inputs]).filter((value) => value !== undefined)
1646
+ for (const value of list) {
1647
+ if (!nonEmpty(value) || !isAbsolute(value)) throw new UsageError('--input must be an absolute canonical rebuild input path')
1648
+ }
1649
+ if (format === 'generic') {
1650
+ if (list.length) throw new UsageError('--input is only meaningful with a typed --format; a generic resource records no rebuild input proof')
1651
+ return []
1652
+ }
1653
+ if (!list.length) throw new UsageError(`--format ${format} needs its canonical rebuild inputs (--input <absolute path>)`)
1654
+ return list
1655
+ }
1656
+
1657
+ export function registerResource({
1658
+ repo, run, unit, path, kind, lastUsedAt, recipe, releaseOwner = false, format = 'generic', inputs = [], dryRun = false, now,
1659
+ bounds = {},
1660
+ } = {}) {
1661
+ const nowMs = nowValue(now)
1662
+ const { root } = canonicalRoot(repo, { mustExist: true })
1663
+ if (!nonEmpty(unit)) throw new UsageError('--unit must be a unit id')
1664
+ if (!KINDS.has(kind)) throw new UsageError('--kind must be generated or artifact')
1665
+ if (!nonEmpty(path) || !isAbsolute(path)) throw new UsageError('--path must be an absolute path')
1666
+ const rebuildInputs = formatInputs(format, inputs)
1667
+ if (lastUsedAt === undefined) throw new UsageError('--last-used-at is required; activity dates are caller-supplied, never inferred')
1668
+ const usedAt = assertTimestamp(lastUsedAt, '--last-used-at')
1669
+ if (timestamp(usedAt) > nowMs) throw new RetentionError('timestamp', `--last-used-at ${usedAt} is in the future`)
1670
+ if (kind === 'generated' && !nonEmpty(recipe)) throw new UsageError('a generated resource needs a nonempty --recipe for reconstruction')
1671
+ if (kind === 'artifact' && recipe !== undefined && typeof recipe !== 'string') throw new UsageError('--recipe must be text')
1672
+ if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
1673
+ const runReal = canonicalExistingDir(run, 'run directory')
1674
+ const rootReal = real(root)
1675
+ if (!strictInside(rootReal, runReal)) throw new RetentionError('ownership', `run ${runReal} is not inside the canonical root ${root}`)
1676
+ if (symlinkOnPath(rootReal, resolve(run))) throw new RetentionError('symlink', `run ${run} passes through a symbolic link`)
1677
+ const planPath = join(runReal, 'task-plan.json')
1678
+ const plan = readPlanFile(planPath)
1679
+ if (!plan) throw new RetentionError('plan', `owner task-plan ${planPath} is missing or unreadable`)
1680
+ const member = unitOfPlan(plan, unit)
1681
+ if (!member) throw new RetentionError('ownership', `unit ${unit} is not recorded in ${planPath}`)
1682
+ const lexicalTarget = resolve(path)
1683
+ const lexicalRelative = relative(rootReal, lexicalTarget)
1684
+ if (!lexicalRelative.startsWith(`..${sep}`) && lexicalRelative !== '..' && !isAbsolute(lexicalRelative)
1685
+ && symlinkOnPath(rootReal, lexicalTarget)) {
1686
+ throw new RetentionError('symlink', `${lexicalTarget} passes through a symbolic link below the canonical root`)
1687
+ }
1688
+ const target = canonicalExistingDir(path, 'resource path')
1689
+ if (!strictInside(real(root), target)) throw new RetentionError('ownership', `${target} is not inside the canonical root ${root}`)
1690
+ if (symlinkOnPath(real(root), target)) throw new RetentionError('symlink', `${target} passes through a symbolic link`)
1691
+ const ownership = ownershipProblem({ root, run: runReal, unit: member, unitId: unit, target, plan })
1692
+ if (ownership) throw new RetentionError('ownership', ownership)
1693
+ const completed = unitCompleted(member)
1694
+ if (member.worker != null && member.worker !== '') {
1695
+ throw new RetentionError('owner-active', `unit ${unit} records active worker ${member.worker}; release it before registering its resources`)
1696
+ }
1697
+ // Inventory is recorded before worker admission. Only eligibility grants a
1698
+ // release; registering unfinished work must not authorize its deletion.
1699
+ const inspection = inspectTree(target, { ...normalizeBounds(bounds), hashContent: true })
1700
+ // The typed proof is taken from this same inspection, so registering a format costs one
1701
+ // tree walk, not two. Nothing is relaxed until the format is positively proven.
1702
+ const typed = format === 'generic' ? null : inspectResourceFormat({ format, path: target, inputs: rebuildInputs, inspection })
1703
+ if (typed && !typed.ok) throw new RetentionError('format', `${format} is not proven for ${target}: ${typed.reason}`)
1704
+ const unsafe = treeSafety(target, inspection, typed)
1705
+ if (unsafe) throw new RetentionError('refused', unsafe)
1706
+ const index = planIndex(real(root), normalizeBounds(bounds))
1707
+ const evidence = requiredEvidenceOverlap(target, index.evidence)
1708
+ if (evidence) throw new RetentionError('evidence', evidence)
1709
+ const claim = claimConflict(target, index.claims, { run: runReal, unit })
1710
+ if (claim) throw new RetentionError('overlap', claim.reason)
1711
+ const id = `${kind}-${createHash('sha256').update(target).digest('hex').slice(0, 12)}`
1712
+ const identity = { dev: lstatSync(target).dev, ino: lstatSync(target).ino }
1713
+ const entryFor = (existing) => ({
1714
+ id,
1715
+ path: target,
1716
+ kind,
1717
+ format,
1718
+ ...(typed ? { inputProofs: typed.inputProofs } : {}),
1719
+ owner: { run: runReal, unit: String(unit) },
1720
+ lastUsedAt: usedAt,
1721
+ disposable: true,
1722
+ pinned: existing?.pinned === true,
1723
+ recipe: kind === 'generated' ? String(recipe).trim() : String(recipe ?? ''),
1724
+ identity,
1725
+ authorization: { source: 'explicit-registration', at: isoTimestamp(nowMs) },
1726
+ ...(releaseOwner === true ? { ownerReleased: true } : {}),
1727
+ })
1728
+ const registered = (entry, saved, existing) => ({
1729
+ version: 1, command: 'register', ok: true, applied: !dryRun, dryRun,
1730
+ repo: canonicalRepo(repo), root, manifest: saved,
1731
+ entry,
1732
+ resource: {
1733
+ id, path: target, kind, format, identity, owner: entry.owner,
1734
+ ...(typed ? { inputProofs: typed.inputProofs } : {}),
1735
+ },
1736
+ authorization: entry.authorization,
1737
+ ownerCompleted: completed,
1738
+ ownerReleased: entry.ownerReleased === true,
1739
+ ...(existing ? { refreshed: true } : {}),
1740
+ ...(typed ? { markers: typed.markers } : {}),
1741
+ })
1742
+ if (dryRun) {
1743
+ // Preview: every safety and reuse check above ran, but nothing was acquired or written.
1744
+ const manifest = readManifest(root)
1745
+ const existing = manifest.entries.find((item) => item?.id === id)
1746
+ if (!existing) {
1747
+ const overlap = overlappingPairs([...manifest.entries, { id, path: target }]).get(manifest.entries.length)
1748
+ if (overlap?.length) throw new RetentionError('overlap', `${target} overlaps registered resource(s) ${overlap.join(', ')}; overlapping registrations are refused`)
1749
+ }
1750
+ return registered(entryFor(existing), manifestPath(root), existing)
1751
+ }
1752
+ const lock = acquireLock(root)
1753
+ try {
1754
+ // Ownership can change while the lock is acquired: another unit or run may have
1755
+ // declared the same tree, so the claim scan is repeated from the plans on disk.
1756
+ const freshClaim = claimConflict(target, planIndex(real(root), normalizeBounds(bounds)).claims, { run: runReal, unit })
1757
+ if (freshClaim) throw new RetentionError('overlap', freshClaim.reason)
1758
+ const manifest = readManifest(root)
1759
+ const existing = manifest.entries.find((entry) => entry?.id === id)
1760
+ if (!existing) {
1761
+ const overlap = overlappingPairs([...manifest.entries, { id, path: target }]).get(manifest.entries.length)
1762
+ if (overlap?.length) throw new RetentionError('overlap', `${target} overlaps registered resource(s) ${overlap.join(', ')}; overlapping registrations are refused`)
1763
+ }
1764
+ const entry = entryFor(existing)
1765
+ const next = existing
1766
+ ? manifest.entries.map((item) => (item?.id === id ? entry : item))
1767
+ : [...manifest.entries, entry]
1768
+ const saved = writeManifest(root, { ...manifest, version: MANIFEST_VERSION, entries: next })
1769
+ return { ...registered(entry, saved, existing), lockRecovery: lock.recovery }
1770
+ } finally {
1771
+ lock.release()
1772
+ }
1773
+ }
1774
+
1775
+ // ---- status ----
1776
+
1777
+ // Read-only reuse verdict for one registered resource. It re-proves the recorded format
1778
+ // from a fresh (cheap, content-free) tree inspection and re-validates the recorded rebuild
1779
+ // inputs; it never writes, never executes the recipe, and never acquires the lock. A
1780
+ // partial root left by a manual cleanup stays partial: presence of the parent directory is
1781
+ // not evidence that the resource is usable.
1782
+ function statusOf(entry, bounds) {
1783
+ const row = {
1784
+ id: entry?.id ?? null,
1785
+ path: entry?.path ?? null,
1786
+ kind: entry?.kind ?? null,
1787
+ format: nonEmpty(entry?.format) ? entry.format : 'generic',
1788
+ recipe: typeof entry?.recipe === 'string' ? entry.recipe : null,
1789
+ owner: entry?.owner ?? null,
1790
+ lastUsedAt: entry?.lastUsedAt ?? null,
1791
+ inputs: Array.isArray(entry?.inputProofs)
1792
+ ? entry.inputProofs.map((proof) => ({ name: proof?.name ?? null, path: proof?.path ?? null }))
1793
+ : [],
1794
+ state: 'invalid',
1795
+ ready: false,
1796
+ rebuildRequired: true,
1797
+ reason: null,
1798
+ }
1799
+ const parsed = parseEntry(entry, { forStatus: true })
1800
+ if (parsed.problems.length) { row.reason = parsed.problems.join('; '); return row }
1801
+ row.format = parsed.format
1802
+ if (entry.cleanupState === 'removed') {
1803
+ row.state = 'removed'
1804
+ row.reason = 'the manifest records this resource as removed; only its recipe can recreate it'
1805
+ return row
1806
+ }
1807
+ const target = resolve(entry.path)
1808
+ const st = lstatOrNull(target)
1809
+ if (!st) {
1810
+ row.state = 'missing'
1811
+ row.reason = `${target} does not exist; rebuild it with the retained recipe`
1812
+ return row
1813
+ }
1814
+ if (st.isSymbolicLink() || !st.isDirectory()) {
1815
+ row.state = 'partial'
1816
+ row.reason = `${target} is not a real directory, so the recorded resource is no longer there`
1817
+ return row
1818
+ }
1819
+ // Readiness is not deletion eligibility. Pins, credentials and reference links
1820
+ // protect cleanup but do not make a structurally valid installation broken.
1821
+ const typed = parsed.format === 'generic'
1822
+ ? { typed: false, ok: true }
1823
+ : { typed: true, ...classifyRebuildState({ format: parsed.format, path: target, inputProofs: entry.inputProofs }) }
1824
+ if (typed.typed && !typed.ok) {
1825
+ // A partial install or a changed lockfile is deferred work, never a silent success.
1826
+ row.state = typed.state === 'missing' ? 'missing' : typed.state === 'inputs-changed' ? 'inputs-changed' : 'partial'
1827
+ row.reason = typed.reason
1828
+ return row
1829
+ }
1830
+ row.state = 'ready'
1831
+ row.ready = true
1832
+ row.rebuildRequired = false
1833
+ row.reason = typed.typed
1834
+ ? `${parsed.format} markers and rebuild inputs still match the registration`
1835
+ : 'present; a generic resource carries no typed format proof'
1836
+ return row
1837
+ }
1838
+
1839
+ function statusFilter({ run, unit } = {}) {
1840
+ if (run === undefined && unit === undefined) return { matches: () => true, describe: null }
1841
+ if (run === undefined || unit === undefined) throw new UsageError('status takes --run and --unit together')
1842
+ if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
1843
+ const runReal = canonicalExistingDir(run, 'run directory')
1844
+ return {
1845
+ matches: (entry) => isObject(entry?.owner) && entry.owner.run === runReal && String(entry.owner.unit) === String(unit),
1846
+ describe: { run: runReal, unit: String(unit) },
1847
+ }
1848
+ }
1849
+
1850
+ export function resourceStatus({ repo, run, unit, bounds = {} } = {}) {
1851
+ const { root, repo: canonical } = canonicalRoot(repo)
1852
+ const filter = statusFilter({ run, unit })
1853
+ const manifest = existsSync(root) ? readManifest(root) : { version: MANIFEST_VERSION, entries: [] }
1854
+ const entries = manifest.entries
1855
+ .filter((entry) => filter.matches(entry))
1856
+ .map((entry) => {
1857
+ try {
1858
+ return statusOf(entry, bounds)
1859
+ } catch (error) {
1860
+ return {
1861
+ id: entry?.id ?? null, path: entry?.path ?? null, state: 'unreadable', ready: false,
1862
+ rebuildRequired: true, reason: `status could not be read: ${error?.message ?? error}`,
1863
+ }
1864
+ }
1865
+ })
1866
+ .sort((a, b) => String(a.path).localeCompare(String(b.path)))
1867
+ const counts = {}
1868
+ for (const entry of entries) counts[entry.state] = (counts[entry.state] ?? 0) + 1
1869
+ return {
1870
+ version: 1, command: 'status', ok: true, readOnly: true, applied: false,
1871
+ repo: canonical, root, filter: filter.describe, entries, counts,
1872
+ summary: {
1873
+ total: entries.length,
1874
+ ready: entries.filter((entry) => entry.ready).length,
1875
+ rebuildRequired: entries.filter((entry) => entry.rebuildRequired).length,
1876
+ byState: counts,
1877
+ },
1878
+ errors: [],
1879
+ }
1880
+ }
1881
+
1882
+ // ---- recover ----
1883
+
1884
+ // Read-only receipt inventory. A receipt is the only record of what an interrupted apply
1885
+ // reached, so recovery reports it and changes nothing about it.
1886
+ function receiptReport(root) {
1887
+ const dir = join(root, RECEIPT_DIR)
1888
+ let names
1889
+ try { names = readdirSync(dir) } catch {
1890
+ return { dir, count: 0, entries: [], counts: { total: 0, applying: 0, complete: 0, failed: 0, other: 0 }, truncated: false }
1891
+ }
1892
+ const files = names.filter((name) => name.endsWith('.json')).sort()
1893
+ const entries = []
1894
+ for (const name of files.slice(0, MAX_STATUS_RECEIPTS)) {
1895
+ const path = join(dir, name)
1896
+ const st = lstatOrNull(path)
1897
+ if (!st || st.isSymbolicLink() || !st.isFile() || st.size > 4 * 1024 * 1024) {
1898
+ entries.push({ file: name, status: 'unreadable' })
1899
+ continue
1900
+ }
1901
+ let saved
1902
+ try { saved = JSON.parse(readFileSync(path, 'utf8')) } catch { entries.push({ file: name, status: 'unreadable' }); continue }
1903
+ entries.push({
1904
+ file: name,
1905
+ id: saved?.id ?? null,
1906
+ path: saved?.path ?? null,
1907
+ status: saved?.status ?? 'unknown',
1908
+ at: saved?.at ?? null,
1909
+ quarantine: saved?.quarantine ?? null,
1910
+ removed: saved?.removed === true,
1911
+ reason: saved?.reason ?? null,
1912
+ })
1913
+ }
1914
+ const counts = { total: files.length, applying: 0, complete: 0, failed: 0, other: 0 }
1915
+ for (const entry of entries) {
1916
+ if (entry.status === 'applying') counts.applying += 1
1917
+ else if (entry.status === 'complete') counts.complete += 1
1918
+ else if (entry.status === 'failed') counts.failed += 1
1919
+ else counts.other += 1
1920
+ }
1921
+ return { dir, count: files.length, entries, counts, truncated: files.length > MAX_STATUS_RECEIPTS }
1922
+ }
1923
+
1924
+ export function recoverRetention({ repo, apply = false } = {}) {
1925
+ const { root } = canonicalRoot(repo)
1926
+ const canonical = canonicalRepo(repo)
1927
+ if (!existsSync(root)) {
1928
+ return {
1929
+ version: 1, command: 'recover', ok: true, applied: false, dryRun: !apply,
1930
+ repo: canonical, root, lockPath: join(root, LOCK_NAME), status: 'absent', action: 'none',
1931
+ reason: 'root-missing', detail: `${root} does not exist`, cleared: { lock: false, tombstones: [] },
1932
+ remaining: { lock: false, tombstones: [] }, tombstones: [], quarantines: [],
1933
+ receipts: { dir: join(root, RECEIPT_DIR), count: 0, entries: [], counts: { total: 0, applying: 0, complete: 0, failed: 0, other: 0 }, truncated: false },
1934
+ resourcesUntouched: true, claimsSuccessor: false,
1935
+ note: 'there is nothing to recover; no cleanup state was read or changed',
1936
+ }
1937
+ }
1938
+ const recovery = recoverRetentionLock(root, { apply })
1939
+ return {
1940
+ ...recovery,
1941
+ version: 1,
1942
+ command: 'recover',
1943
+ repo: canonical,
1944
+ ok: recovery.failures.length === 0,
1945
+ applied: recovery.cleared.lock || recovery.cleared.tombstones.length > 0,
1946
+ receipts: receiptReport(recovery.root),
1947
+ resourcesUntouched: true,
1948
+ claimsSuccessor: false,
1949
+ }
1950
+ }
1951
+
1952
+ // ---- register-owned (bounded batch inventory) ----
1953
+
1954
+ // The batch form reads only what the unit's plan declares. It needs an explicit retention
1955
+ // record per resource and never adopts an unrecorded path, so a path the plan does not
1956
+ // carry stays invisible here no matter how old or how large it is.
1957
+ function retentionSpec(resource) {
1958
+ const raw = resource?.retention
1959
+ if (!isObject(raw)) return { ok: false, reason: 'the declared resource carries no retention metadata ({kind, format, recipe, inputs})' }
1960
+ if (!KINDS.has(raw.kind)) return { ok: false, reason: `retention.kind ${String(raw.kind)} is not generated|artifact` }
1961
+ if (!nonEmpty(raw.format) || !FORMATS.has(raw.format)) {
1962
+ return { ok: false, reason: `retention.format ${String(raw.format)} is not ${[...FORMATS].join('|')}` }
1963
+ }
1964
+ if (typeof raw.recipe !== 'string') return { ok: false, reason: 'retention.recipe must be text' }
1965
+ if (raw.inputs !== undefined && !Array.isArray(raw.inputs)) return { ok: false, reason: 'retention.inputs must be an array of absolute paths' }
1966
+ for (const input of Array.isArray(raw.inputs) ? raw.inputs : []) {
1967
+ if (!nonEmpty(input) || !isAbsolute(input)) return { ok: false, reason: 'retention.inputs must be absolute canonical file paths' }
1968
+ }
1969
+ return {
1970
+ ok: true,
1971
+ kind: raw.kind,
1972
+ format: raw.format,
1973
+ recipe: raw.recipe,
1974
+ inputs: Array.isArray(raw.inputs) ? raw.inputs : [],
1975
+ }
1976
+ }
1977
+
1978
+ export function registerOwned({ repo, run, unit, lastUsedAt, apply = false, now, bounds = {} } = {}) {
1979
+ const nowMs = nowValue(now)
1980
+ const { root } = canonicalRoot(repo, { mustExist: true })
1981
+ if (!nonEmpty(unit)) throw new UsageError('--unit must be a unit id')
1982
+ if (!nonEmpty(run) || !isAbsolute(run)) throw new UsageError('--run must be an absolute run directory')
1983
+ if (lastUsedAt === undefined) throw new UsageError('--last-used-at is required; activity dates are caller-supplied, never inferred')
1984
+ const usedAt = assertTimestamp(lastUsedAt, '--last-used-at')
1985
+ if (timestamp(usedAt) > nowMs) throw new RetentionError('timestamp', `--last-used-at ${usedAt} is in the future`)
1986
+ const runReal = canonicalExistingDir(run, 'run directory')
1987
+ const rootReal = real(root)
1988
+ if (!strictInside(rootReal, runReal)) throw new RetentionError('ownership', `run ${runReal} is not inside the canonical root ${root}`)
1989
+ if (symlinkOnPath(rootReal, resolve(run))) throw new RetentionError('symlink', `run ${run} passes through a symbolic link`)
1990
+ const planPath = join(runReal, 'task-plan.json')
1991
+ const plan = readPlanFile(planPath)
1992
+ if (!plan) throw new RetentionError('plan', `owner task-plan ${planPath} is missing or unreadable`)
1993
+ const member = unitOfPlan(plan, unit)
1994
+ if (!member) throw new RetentionError('ownership', `unit ${unit} is not recorded in ${planPath}`)
1995
+ const declared = member.lifecycle?.resources
1996
+ if (declared !== undefined && !Array.isArray(declared)) {
1997
+ throw new RetentionError('plan', `unit ${unit} resources are not an array, so the declared inventory cannot be read`)
1998
+ }
1999
+ const declaredTotal = Array.isArray(declared) ? declared.length : 0
2000
+ const list = (Array.isArray(declared) ? declared : []).slice(0, MAX_BATCH_RESOURCES)
2001
+ const truncated = list.length < declaredTotal
2002
+ const registrations = []
2003
+ const deferred = []
2004
+ const uncovered = []
2005
+ const failed = []
2006
+ for (const resource of list) {
2007
+ const declaredPath = isObject(resource) && nonEmpty(resource.path) ? resource.path : null
2008
+ const lexical = declaredPath === null ? null : (isAbsolute(declaredPath) ? resolve(declaredPath) : resolve(runReal, declaredPath))
2009
+ if (!lexical) {
2010
+ uncovered.push({ path: declaredPath, reason: 'the declared resource names no path, so it cannot be registered' })
2011
+ continue
2012
+ }
2013
+ const spec = retentionSpec(resource)
2014
+ if (!spec.ok) {
2015
+ uncovered.push({ path: lexical, reason: spec.reason })
2016
+ continue
2017
+ }
2018
+ try {
2019
+ const result = registerResource({
2020
+ repo, run: runReal, unit, path: lexical, kind: spec.kind, format: spec.format,
2021
+ inputs: spec.inputs, recipe: spec.recipe, lastUsedAt: usedAt, now: nowMs, bounds,
2022
+ dryRun: !apply,
2023
+ })
2024
+ registrations.push({
2025
+ path: result.resource.path,
2026
+ id: result.resource.id,
2027
+ kind: spec.kind,
2028
+ format: spec.format,
2029
+ recipe: spec.recipe,
2030
+ inputs: spec.inputs,
2031
+ action: apply ? 'registered' : 'would-register',
2032
+ state: apply ? 'registered' : 'verified',
2033
+ refreshed: result.refreshed === true,
2034
+ ownerReleased: result.ownerReleased === true,
2035
+ })
2036
+ } catch (error) {
2037
+ const entry = {
2038
+ path: lexical,
2039
+ kind: spec.kind,
2040
+ format: spec.format,
2041
+ recipe: spec.recipe,
2042
+ inputs: spec.inputs,
2043
+ state: 'deferred',
2044
+ deferredReason: error?.code ?? 'error',
2045
+ reason: error?.message ?? String(error),
2046
+ }
2047
+ // An I/O failure is not a deferral: the command must not report full coverage.
2048
+ if (entry.deferredReason === 'io') failed.push(entry)
2049
+ else deferred.push(entry)
2050
+ }
2051
+ }
2052
+ const counts = {
2053
+ declared: list.length,
2054
+ declaredTotal,
2055
+ registrations: registrations.length,
2056
+ deferred: deferred.length,
2057
+ uncovered: uncovered.length,
2058
+ failed: failed.length,
2059
+ bounded: truncated,
2060
+ }
2061
+ const complete = deferred.length === 0 && uncovered.length === 0 && failed.length === 0 && !truncated
2062
+ return {
2063
+ version: 1, command: 'register-owned', ok: complete, applied: apply, dryRun: !apply,
2064
+ repo: canonicalRepo(repo), root, run: runReal, unit: String(unit), lastUsedAt: usedAt,
2065
+ ownerCompleted: unitCompleted(member),
2066
+ // The batch form records inventory; it never releases an unfinished owner's resource.
2067
+ ownerReleaseRequested: false,
2068
+ registrations, deferred, uncovered, failed, counts,
2069
+ coverage: {
2070
+ complete,
2071
+ reason: list.length === 0
2072
+ ? `unit ${unit} declares no resources`
2073
+ : truncated
2074
+ ? `the declared inventory exceeds the ${MAX_BATCH_RESOURCES}-resource batch bound; ${declaredTotal - list.length} resource(s) were not examined`
2075
+ : complete
2076
+ ? `every declared resource with retention metadata was ${apply ? 'registered' : 'verified'}`
2077
+ : `${counts.deferred} deferred, ${counts.uncovered} uncovered, ${counts.failed} failed of ${counts.declared} declared`,
2078
+ },
2079
+ errors: failed.map((entry) => entry.reason),
2080
+ }
2081
+ }
2082
+
2083
+ // ---- CLI ----
2084
+
2085
+ function positiveOrZero(value, flag) {
2086
+ const number = Number(value)
2087
+ if (!Number.isSafeInteger(number) || number < 0) throw new UsageError(`${flag} must be a nonnegative integer`)
2088
+ return number
2089
+ }
2090
+
2091
+ const COMMANDS = new Set(['plan', 'apply', 'register', 'register-owned', 'status', 'recover'])
2092
+ const APPLY_COMMANDS = new Set(['apply', 'recover', 'register-owned'])
2093
+
2094
+ // Which flags each command accepts. Anything else is a usage error, so a misplaced flag can
2095
+ // never quietly change what a command does.
2096
+ const COMMAND_FLAGS = {
2097
+ plan: ['--repo', '--generated-days', '--artifact-days'],
2098
+ apply: ['--repo', '--generated-days', '--artifact-days'],
2099
+ register: ['--repo', '--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'],
2100
+ 'register-owned': ['--repo', '--run', '--unit', '--last-used-at'],
2101
+ status: ['--repo', '--run', '--unit'],
2102
+ recover: ['--repo'],
2103
+ }
2104
+ const BOOLEAN_FLAGS = {
2105
+ register: ['--release-owner'],
2106
+ }
2107
+ const REGISTER_ARG_FLAGS = new Set(['--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'])
2108
+
2109
+ export function parseArgs(argv) {
2110
+ const opts = {
2111
+ command: null, repo: null, generatedDays: 7, artifactDays: 30, json: false, apply: false,
2112
+ run: null, unit: null, path: null, kind: null, lastUsedAt: null, recipe: undefined,
2113
+ format: null, inputs: [], releaseOwner: false, help: false,
2114
+ }
2115
+ const seen = new Set()
2116
+ for (let i = 0; i < argv.length; i++) {
2117
+ const arg = argv[i]
2118
+ if (!opts.command && COMMANDS.has(arg)) { opts.command = arg; continue }
2119
+ if (arg === '--apply') { opts.apply = true; continue }
2120
+ if (arg === '--json') { opts.json = true; continue }
2121
+ if (arg === '--release-owner') { opts.releaseOwner = true; continue }
2122
+ if (arg === '--help' || arg === '-h') { opts.help = true; continue }
2123
+ const value = () => {
2124
+ if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) throw new UsageError(`${arg} needs a value`)
2125
+ return argv[++i]
2126
+ }
2127
+ if (arg === '--repo') opts.repo = value()
2128
+ else if (arg === '--generated-days') opts.generatedDays = positiveOrZero(value(), '--generated-days')
2129
+ else if (arg === '--artifact-days') opts.artifactDays = positiveOrZero(value(), '--artifact-days')
2130
+ else if (arg === '--run') opts.run = value()
2131
+ else if (arg === '--unit') opts.unit = value()
2132
+ else if (arg === '--path') opts.path = value()
2133
+ else if (arg === '--kind') opts.kind = value()
2134
+ else if (arg === '--last-used-at') opts.lastUsedAt = value()
2135
+ else if (arg === '--recipe') opts.recipe = value()
2136
+ else if (arg === '--format') opts.format = value()
2137
+ else if (arg === '--input') opts.inputs.push(value())
2138
+ else throw new UsageError(`unrecognized argument ${arg}`)
2139
+ if (['--generated-days', '--artifact-days', '--run', '--unit', '--path', '--kind', '--last-used-at', '--recipe', '--format', '--input'].includes(arg)) seen.add(arg)
2140
+ }
2141
+ if (opts.help) return opts
2142
+ if (!opts.command) opts.command = opts.apply ? 'apply' : 'plan'
2143
+ if (opts.apply && !APPLY_COMMANDS.has(opts.command)) throw new UsageError(`${opts.command} does not take --apply`)
2144
+ if (!opts.repo) throw new UsageError('--repo is required')
2145
+ const allowed = new Set(COMMAND_FLAGS[opts.command])
2146
+ const booleans = new Set(BOOLEAN_FLAGS[opts.command] ?? [])
2147
+ for (const flag of seen) {
2148
+ if (!allowed.has(flag)) {
2149
+ throw new UsageError(REGISTER_ARG_FLAGS.has(flag)
2150
+ ? `${opts.command} does not take register arguments (${flag})`
2151
+ : `${opts.command} does not take ${flag}`)
2152
+ }
2153
+ }
2154
+ if (opts.releaseOwner && !booleans.has('--release-owner')) throw new UsageError(`${opts.command} does not take --release-owner`)
2155
+ if (opts.command === 'register') {
2156
+ for (const [flag, key] of [['--run', 'run'], ['--unit', 'unit'], ['--path', 'path'], ['--kind', 'kind'], ['--last-used-at', 'lastUsedAt']]) {
2157
+ if (!nonEmpty(opts[key])) throw new UsageError(`register needs ${flag}`)
2158
+ }
2159
+ }
2160
+ if (opts.command === 'register-owned') {
2161
+ if (!nonEmpty(opts.run)) throw new UsageError('register-owned needs --run')
2162
+ if (!nonEmpty(opts.unit)) throw new UsageError('register-owned needs --unit')
2163
+ }
2164
+ if (opts.command === 'status' && nonEmpty(opts.run) !== nonEmpty(opts.unit)) {
2165
+ throw new UsageError('status takes --run and --unit together')
2166
+ }
2167
+ if (opts.command === 'register-owned' && !nonEmpty(opts.lastUsedAt)) throw new UsageError('register-owned needs --last-used-at')
2168
+ return opts
2169
+ }
2170
+
2171
+ function renderPlan(result) {
2172
+ const lines = [`retention plan (read-only): ${result.root}`,
2173
+ ` generated ${result.generatedDays}d, artifact ${result.artifactDays}d`]
2174
+ for (const row of result.entries) {
2175
+ lines.push(` ${row.status.toUpperCase().padEnd(16)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
2176
+ }
2177
+ const s = result.summary
2178
+ lines.push(` ${s.total} entr${s.total === 1 ? 'y' : 'ies'}: ${s.ready} ready (${s.candidatesBytes} byte(s)), ${s.retained} retained, ${s.refused} refused, ${s.alreadyRemoved} already removed`)
2179
+ if (result.inventory.runs.length) {
2180
+ lines.push(' largest run directories (inventory only, never removal candidates):')
2181
+ for (const run of result.inventory.runs.slice(0, 10)) lines.push(` ${run.bytes} ${run.run}${run.truncated ? ' (bounded)' : ''}`)
2182
+ }
2183
+ if (result.inventory.resources?.length) {
2184
+ lines.push(' largest run resources (inventory only; only manifest entries are candidates):')
2185
+ for (const resource of result.inventory.resources.slice(0, 10)) lines.push(` ${resource.bytes} ${resource.path}${resource.truncated ? ' (bounded)' : ''}`)
2186
+ }
2187
+ return lines.join('\n')
2188
+ }
2189
+
2190
+ function renderApply(result) {
2191
+ const lines = [`retention apply: ${result.root}`]
2192
+ for (const row of result.entries) {
2193
+ lines.push(` ${row.action.toUpperCase().padEnd(16)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
2194
+ }
2195
+ lines.push(` ${result.entries.length} row(s), ${result.ioFailures.length} I/O failure(s)`)
2196
+ return lines.join('\n')
2197
+ }
2198
+
2199
+ function renderRegister(result) {
2200
+ return [
2201
+ `${result.dryRun ? 'would register' : 'registered'} ${result.resource.kind} ${result.resource.path}`,
2202
+ ` id ${result.resource.id} format ${result.resource.format} identity ${result.resource.identity.dev}/${result.resource.identity.ino} owner ${result.resource.owner.unit}`,
2203
+ ...(result.resource.inputProofs ? result.resource.inputProofs.map((proof) => ` input ${proof.name} ${proof.path}`) : []),
2204
+ ` authorization ${result.authorization.source} at ${result.authorization.at}${result.ownerReleased ? ' (owner released)' : ''}`,
2205
+ ` manifest ${result.manifest}${result.dryRun ? ' (not written; preview)' : ''}`,
2206
+ ].join('\n')
2207
+ }
2208
+
2209
+ function renderStatus(result) {
2210
+ const lines = [`retention status (read-only): ${result.root}`]
2211
+ if (result.filter) lines.push(` filter run ${result.filter.run} unit ${result.filter.unit}`)
2212
+ for (const row of result.entries) {
2213
+ lines.push(` ${row.state.toUpperCase().padEnd(15)} ${row.path ?? '(invalid entry)'} ${row.reason ?? ''}`)
2214
+ if (row.recipe) lines.push(` recipe: ${row.recipe}`)
2215
+ if (row.inputs?.length) lines.push(` inputs: ${row.inputs.map((input) => `${input.name} ${input.path}`).join(', ')}`)
2216
+ }
2217
+ lines.push(` ${result.summary.total} registered: ${result.summary.ready} ready, ${result.summary.rebuildRequired} rebuild required`)
2218
+ return lines.join('\n')
2219
+ }
2220
+
2221
+ function renderRecover(result) {
2222
+ const lines = [
2223
+ `retention recover${result.dryRun ? ' (read-only)' : ''}: ${result.root}`,
2224
+ ` status ${result.status} action ${result.action} reason ${result.reason ?? '(none)'} ${result.detail ?? ''}`,
2225
+ ` cleared lock ${result.cleared.lock ? 'yes' : 'no'}, tombstones ${result.cleared.tombstones.length}`,
2226
+ ]
2227
+ const receipts = result.receipts
2228
+ lines.push(` receipts: ${receipts.counts.total} total, ${receipts.counts.applying} applying, ${receipts.counts.failed} failed, ${receipts.counts.complete} complete (unchanged)`)
2229
+ for (const receipt of receipts.entries.filter((entry) => entry.status !== 'complete').slice(0, 10)) {
2230
+ lines.push(` ${receipt.status} ${receipt.id ?? receipt.file} ${receipt.path ?? ''} ${receipt.quarantine ? `quarantine ${receipt.quarantine}` : ''}`)
2231
+ }
2232
+ lines.push(' no resource, receipt, or quarantined tree was removed; recovery never claims a successor apply')
2233
+ return lines.join('\n')
2234
+ }
2235
+
2236
+ function renderRegisterOwned(result) {
2237
+ const lines = [
2238
+ `retention register-owned${result.dryRun ? ' (preview)' : ''}: ${result.root}`,
2239
+ ` run ${result.run} unit ${result.unit} last-used ${result.lastUsedAt}`,
2240
+ ]
2241
+ for (const row of result.registrations) lines.push(` ${row.action.toUpperCase().padEnd(16)} ${row.path} ${row.format} ${row.kind}`)
2242
+ for (const row of result.deferred) lines.push(` DEFERRED ${row.path} ${row.reason}`)
2243
+ for (const row of result.failed) lines.push(` FAILED ${row.path} ${row.reason}`)
2244
+ for (const row of result.uncovered) lines.push(` UNCOVERED ${row.path ?? '(no path)'} ${row.reason}`)
2245
+ lines.push(` ${result.counts.declared} declared: ${result.counts.registrations} ${result.dryRun ? 'verified' : 'registered'}, ${result.counts.deferred} deferred, ${result.counts.uncovered} uncovered, ${result.counts.failed} failed`)
2246
+ lines.push(` coverage ${result.coverage.complete ? 'complete' : 'partial'}: ${result.coverage.reason}`)
2247
+ return lines.join('\n')
2248
+ }
2249
+
2250
+ function failure(command, error) {
2251
+ const code = error instanceof UsageError ? 'usage' : (error?.code ?? 'error')
2252
+ return { version: 1, command, ok: false, applied: false, error: { code, message: error?.message ?? String(error) }, errors: [error?.message ?? String(error)] }
2253
+ }
2254
+
2255
+ export function run(argv, runtime = {}) {
2256
+ let opts
2257
+ try { opts = parseArgs(argv) } catch (error) {
2258
+ return { code: 2, error: error.message, json: failure(null, error), text: `retention: ${error.message}\n${USAGE}` }
2259
+ }
2260
+ if (opts.help) return { code: 0, text: `${USAGE}\n` }
2261
+ const shared = {
2262
+ repo: opts.repo,
2263
+ generatedDays: opts.generatedDays,
2264
+ artifactDays: opts.artifactDays,
2265
+ ...(runtime.now !== undefined ? { now: runtime.now } : {}),
2266
+ ...(runtime.probe ? { probe: runtime.probe } : {}),
2267
+ ...(runtime.bounds ? { bounds: runtime.bounds } : {}),
2268
+ ...(runtime.hooks ? { hooks: runtime.hooks } : {}),
2269
+ }
2270
+ try {
2271
+ if (opts.command === 'register') {
2272
+ const result = registerResource({
2273
+ ...shared, run: opts.run, unit: opts.unit, path: opts.path, kind: opts.kind,
2274
+ lastUsedAt: opts.lastUsedAt, recipe: opts.recipe, releaseOwner: opts.releaseOwner,
2275
+ ...(opts.format !== null ? { format: opts.format } : {}),
2276
+ inputs: opts.inputs,
2277
+ })
2278
+ return { code: 0, json: result, text: renderRegister(result) }
2279
+ }
2280
+ if (opts.command === 'register-owned') {
2281
+ const result = registerOwned({
2282
+ ...shared, run: opts.run, unit: opts.unit, lastUsedAt: opts.lastUsedAt, apply: opts.apply,
2283
+ })
2284
+ return { code: result.ok ? 0 : 1, json: result, text: renderRegisterOwned(result) }
2285
+ }
2286
+ if (opts.command === 'status') {
2287
+ const result = resourceStatus({ ...shared, run: opts.run ?? undefined, unit: opts.unit ?? undefined })
2288
+ return { code: 0, json: result, text: renderStatus(result) }
2289
+ }
2290
+ if (opts.command === 'recover') {
2291
+ const result = recoverRetention({ repo: opts.repo, apply: opts.apply })
2292
+ return { code: result.failures?.length ? 1 : 0, json: result, text: renderRecover(result) }
2293
+ }
2294
+ if (opts.command === 'apply') {
2295
+ const result = applyRetention(shared)
2296
+ return { code: result.ioFailures.length ? 1 : 0, json: result, text: renderApply(result) }
2297
+ }
2298
+ const result = planRetention(shared)
2299
+ return { code: 0, json: result, text: renderPlan(result) }
2300
+ } catch (error) {
2301
+ const code = error instanceof RetentionError ? (error.code === 'io' ? 1 : 2) : 1
2302
+ return { code, error: error.message, json: failure(opts.command, error), text: `retention: ${error.message}` }
2303
+ }
2304
+ }
2305
+
2306
+ export function main(argv) {
2307
+ const result = run(argv)
2308
+ if (argv.includes('--json')) process.stdout.write(`${JSON.stringify(result.json ?? failure(null, new Error(result.error ?? 'unknown error')), null, 2)}\n`)
2309
+ else if (result.error) process.stderr.write(`${result.text}\n`)
2310
+ else process.stdout.write(`${result.text}\n`)
2311
+ return result.code
2312
+ }
2313
+
2314
+ if (isMainModule(import.meta.url)) process.exitCode = main(process.argv.slice(2))