@fro.bot/systematic 3.16.5 → 3.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1391 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Preview and snapshot-bound deletion scanner for historical `ce:review`
4
+ // run directories.
5
+ //
6
+ // `preview` never mutates the filesystem: it resolves a caller-supplied
7
+ // project root, canonicalizes it, and walks direct child directories of
8
+ // `.context/systematic/ce-review` to report which are older than a
9
+ // caller-supplied age cutoff, returning a bounded token over that scan.
10
+ //
11
+ // `execute` deletes only the exact candidates the token was built from. It
12
+ // rescans using the token's own fixed time boundary (never "now"), and
13
+ // deletes nothing at all if that rescan's digest no longer matches the
14
+ // token -- a changed root, changed membership, or changed candidate
15
+ // invalidates the whole approval. A token is not proof of human consent;
16
+ // the caller (a skill) must still ask separately before invoking execute.
17
+ //
18
+ // Usage:
19
+ // node cleanup.mjs preview --root <path> --age <Nd|Nw|N> --ack-offline
20
+ // node cleanup.mjs execute --root <path> --ack-offline --token <token>
21
+ //
22
+ // Output: a single JSON object on stdout. Each reported name is
23
+ // length-bounded via boundedName(), and each candidate's subtree is bounded
24
+ // via MAX_ENTRIES/MAX_DEPTH -- but the root's direct-child and
25
+ // selected/excluded/skipped candidate *counts* are not separately capped,
26
+ // so overall output size scales with the number of ce-review run
27
+ // directories under the root. No absolute paths, nested relative paths,
28
+ // subprocess errors, or artifact contents are ever emitted -- only fixed
29
+ // diagnostic categories, bounded/JSON-escaped run names, and hash-derived
30
+ // display ids.
31
+
32
+ import { createHash } from 'node:crypto'
33
+ import {
34
+ closeSync,
35
+ constants as fsConstants,
36
+ fstatSync,
37
+ lstatSync,
38
+ openSync,
39
+ readdirSync,
40
+ readSync,
41
+ realpathSync,
42
+ rmSync,
43
+ } from 'node:fs'
44
+ import { basename, join } from 'node:path'
45
+ import { pathToFileURL } from 'node:url'
46
+
47
+ // ── Constants ─────────────────────────────────────────────────────────────
48
+
49
+ const SCHEMA_VERSION = 1
50
+ const TOKEN_VERSION = 1
51
+ const MAX_DEPTH = 32
52
+ const MAX_ENTRIES = 10_000
53
+ const MAX_SUMMARY_BYTES = 1 * 1024 * 1024 // 1 MiB
54
+ const MAX_NAME_LENGTH = 200
55
+ // ECMA-262 Date's representable range is exactly +/-100,000,000 days (ms)
56
+ // from the epoch. An age cutoff whose duration would push a valid "now"
57
+ // reference time's cutoff outside that range must be rejected up front,
58
+ // before any Date is ever constructed from it.
59
+ const MAX_REPRESENTABLE_DATE_MS = 8_640_000_000_000_000
60
+ const REVIEW_ROOT_SEGMENTS = ['.context', 'systematic', 'ce-review']
61
+ const SUMMARY_FILE_NAME = 'review-summary.json'
62
+ const KNOWN_RUN_STATUSES = new Set([
63
+ 'in_progress',
64
+ 'completed',
65
+ 'degraded',
66
+ 'abnormal',
67
+ ])
68
+
69
+ // Fixed diagnostic categories. Never combine with dynamic/raw text.
70
+ const CATEGORY = Object.freeze({
71
+ INVALID_AGE: 'invalid-age',
72
+ INVALID_ARGUMENTS: 'invalid-arguments',
73
+ INVALID_ROOT: 'invalid-root',
74
+ INVALID_TOKEN: 'invalid-token',
75
+ MISSING_ACKNOWLEDGMENT: 'missing-acknowledgment',
76
+ MISSING_AGE: 'missing-age',
77
+ MISSING_ROOT: 'missing-root-argument',
78
+ MISSING_TOKEN: 'missing-token',
79
+ INTERNAL_ERROR: 'internal-error',
80
+ ROOT_ENUMERATION_FAILED: 'root-enumeration-failed',
81
+ UNKNOWN_OPERATION: 'unknown-operation',
82
+ UNSAFE_REVIEW_ROOT: 'unsafe-review-root',
83
+ })
84
+
85
+ // ── Pure helpers (exported for deterministic Node subprocess tests) ────────
86
+
87
+ /**
88
+ * Parses a positive-integer age cutoff, optionally suffixed with `d` (days,
89
+ * default) or `w` (weeks). Rejects zero, decimals, negative values,
90
+ * malformed text, and arithmetic that would overflow a safe integer. Does
91
+ * not reject a large duration by itself -- whether the *derived cutoff*
92
+ * (which also depends on the reference time) is a representable `Date` is
93
+ * checked by the caller once both are known; see MAX_REPRESENTABLE_DATE_MS.
94
+ * @param {unknown} input
95
+ * @returns {{ days: number, ms: number } | undefined}
96
+ */
97
+ export function parseAgeCutoff(input) {
98
+ if (typeof input !== 'string') return undefined
99
+ const match = /^([1-9]\d*)([dw]?)$/.exec(input.trim())
100
+ if (!match) return undefined
101
+ const amount = Number.parseInt(match[1], 10)
102
+ if (!Number.isSafeInteger(amount) || amount <= 0) return undefined
103
+ const unit = match[2] || 'd'
104
+ const days = unit === 'w' ? amount * 7 : amount
105
+ if (!Number.isSafeInteger(days) || days <= 0) return undefined
106
+ const ms = days * 86_400_000
107
+ if (!Number.isSafeInteger(ms) || ms <= 0) return undefined
108
+ return { days, ms }
109
+ }
110
+
111
+ /**
112
+ * Computes the absolute cutoff time (ms since epoch): candidates whose
113
+ * maximum modification time is strictly earlier than this are eligible.
114
+ * @param {number} referenceTimeMs
115
+ * @param {number} ageDurationMs
116
+ * @returns {number}
117
+ */
118
+ export function computeCutoffTimeMs(referenceTimeMs, ageDurationMs) {
119
+ return referenceTimeMs - ageDurationMs
120
+ }
121
+
122
+ /**
123
+ * Truncates a name to a fixed maximum length so a single pathological entry
124
+ * cannot produce unbounded output. JSON.stringify still escapes any control
125
+ * characters the truncated name contains.
126
+ * @param {string} name
127
+ * @param {number} [maxLength]
128
+ * @returns {string}
129
+ */
130
+ export function boundedName(name, maxLength = MAX_NAME_LENGTH) {
131
+ if (name.length <= maxLength) return name
132
+ return `${name.slice(0, maxLength)}…`
133
+ }
134
+
135
+ /**
136
+ * Widens a fixed starting prefix length across a set of already-computed
137
+ * hex digests until every prefix is unique (or the full digest length is
138
+ * reached). Pure and digest-agnostic so tests can exercise the widening
139
+ * logic directly with synthetic digests, without needing to engineer a
140
+ * real SHA-256 collision.
141
+ * @param {readonly string[]} hexDigests
142
+ * @param {{ startLength?: number, step?: number, maxLength?: number }} [options]
143
+ * @returns {string[]} prefixes in the same order as the input digests
144
+ */
145
+ export function widenUniquePrefixes(hexDigests, options = {}) {
146
+ const startLength = options.startLength ?? 8
147
+ const step = options.step ?? 4
148
+ const maxLength = options.maxLength ?? 64
149
+ let prefixLen = startLength
150
+ for (; prefixLen < maxLength; prefixLen += step) {
151
+ const prefixes = hexDigests.map((h) => h.slice(0, prefixLen))
152
+ if (new Set(prefixes).size === prefixes.length) return prefixes
153
+ }
154
+ return hexDigests.map((h) => h.slice(0, maxLength))
155
+ }
156
+
157
+ /**
158
+ * Derives a bounded, collision-resistant display id for each name without
159
+ * the id itself revealing the underlying name (the name is still reported
160
+ * separately, bounded and JSON-escaped).
161
+ * @param {readonly string[]} names
162
+ * @returns {Map<string, string>} name -> displayId
163
+ */
164
+ export function deriveDisplayIds(names) {
165
+ const fullHashes = names.map((name) =>
166
+ createHash('sha256').update(name, 'utf8').digest('hex'),
167
+ )
168
+ const prefixes = widenUniquePrefixes(fullHashes)
169
+ const result = new Map()
170
+ names.forEach((name, i) => {
171
+ result.set(name, prefixes[i])
172
+ })
173
+ return result
174
+ }
175
+
176
+ /**
177
+ * Builds a versioned, bounded preview token. The token carries the fixed
178
+ * reference time, normalized age duration, absolute cutoff, and a digest of
179
+ * the scanned snapshot -- all recoverable without external state. It is not
180
+ * proof of human approval; the skill must still ask separately.
181
+ * @param {{ referenceTimeMs: number, ageDurationMs: number, cutoffTimeMs: number, digest: string }} fields
182
+ * @returns {string}
183
+ */
184
+ export function buildPreviewToken(fields) {
185
+ const payload = {
186
+ ageDurationMs: fields.ageDurationMs,
187
+ cutoffTimeMs: fields.cutoffTimeMs,
188
+ digest: fields.digest,
189
+ referenceTimeMs: fields.referenceTimeMs,
190
+ v: TOKEN_VERSION,
191
+ }
192
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
193
+ }
194
+
195
+ // ── Filesystem-safe path resolution ─────────────────────────────────────────
196
+
197
+ /**
198
+ * Walks a fixed sequence of path segments below a trusted canonical root,
199
+ * refusing any symlink or non-directory component. Returns 'missing' the
200
+ * moment a segment does not exist (never created), 'symlink' or
201
+ * 'not-directory' if an existing segment is unsafe, or 'ok' with the final
202
+ * resolved absolute path.
203
+ * @param {string} canonicalRoot
204
+ * @param {readonly string[]} segments
205
+ */
206
+ function resolveSegmentChain(canonicalRoot, segments) {
207
+ let current = canonicalRoot
208
+ for (const segment of segments) {
209
+ current = join(current, segment)
210
+ let stat
211
+ try {
212
+ stat = lstatSync(current)
213
+ } catch {
214
+ return { status: 'missing' }
215
+ }
216
+ if (stat.isSymbolicLink()) return { status: 'symlink' }
217
+ if (!stat.isDirectory()) return { status: 'not-directory' }
218
+ }
219
+ return { path: current, status: 'ok' }
220
+ }
221
+
222
+ // ── Per-entry stat (reusable by Unit 3's per-candidate rescan) ──────────────
223
+
224
+ /**
225
+ * Lstats a single path and classifies it. Never follows symlinks. This is
226
+ * the single point of "real filesystem truth" the walker and any future
227
+ * rescan share -- calling it against a missing or replaced path produces a
228
+ * genuine, deterministic lstat failure, not a simulated one.
229
+ * @param {string} absPath
230
+ * @returns {{ ok: true, type: 'file' | 'directory', dev: string, ino: string, mode: string, size: string, mtimeNs: string, ctimeNs: string } | { ok: false, reason: 'unreadable' | 'symlink' | 'special-file' }}
231
+ */
232
+ export function statSnapshotEntry(absPath) {
233
+ let stat
234
+ try {
235
+ stat = lstatSync(absPath, { bigint: true })
236
+ } catch {
237
+ return { ok: false, reason: 'unreadable' }
238
+ }
239
+ if (stat.isSymbolicLink()) return { ok: false, reason: 'symlink' }
240
+ let type
241
+ if (stat.isDirectory()) type = 'directory'
242
+ else if (stat.isFile()) type = 'file'
243
+ else return { ok: false, reason: 'special-file' }
244
+
245
+ return {
246
+ ctimeNs: stat.ctimeNs.toString(),
247
+ dev: stat.dev.toString(),
248
+ ino: stat.ino.toString(),
249
+ mode: stat.mode.toString(),
250
+ mtimeNs: stat.mtimeNs.toString(),
251
+ ok: true,
252
+ size: stat.size.toString(),
253
+ type,
254
+ }
255
+ }
256
+
257
+ // ── Candidate subtree walk ──────────────────────────────────────────────────
258
+
259
+ /**
260
+ * @typedef {{
261
+ * relativePath: string,
262
+ * type: 'file' | 'directory',
263
+ * dev: string,
264
+ * ino: string,
265
+ * mode: string,
266
+ * size: string,
267
+ * mtimeNs: string,
268
+ * ctimeNs: string,
269
+ * }} SnapshotEntry
270
+ */
271
+
272
+ /**
273
+ * Recursively lstats a candidate directory and every descendant via
274
+ * {@link statSnapshotEntry}, rejecting the whole candidate on any symlink,
275
+ * special file, unreadable entry, or traversal bound violation.
276
+ *
277
+ * The subtree's maximum modification time is tracked as a BigInt count of
278
+ * nanoseconds, not a float. Epoch nanosecond values exceed float64's exact
279
+ * integer range (2^53), so converting through `Number` before comparing
280
+ * would introduce rounding noise large enough to flip a strict
281
+ * older-than-cutoff comparison at exact-boundary inputs.
282
+ * @param {string} candidateAbsPath
283
+ * @returns {{ ok: true, entries: SnapshotEntry[], maxMtimeNs: bigint | null } | { ok: false, reason: string }}
284
+ */
285
+ export function walkCandidateSubtree(candidateAbsPath) {
286
+ /** @type {SnapshotEntry[]} */
287
+ const entries = []
288
+ /** @type {bigint | null} */
289
+ let maxMtimeNs = null
290
+ let count = 0
291
+
292
+ /**
293
+ * @param {string} absPath
294
+ * @param {string} relPath
295
+ * @param {number} depth
296
+ * @returns {{ ok: true } | { ok: false, reason: string }}
297
+ */
298
+ function visit(absPath, relPath, depth) {
299
+ if (depth > MAX_DEPTH) return { ok: false, reason: 'depth-limit' }
300
+
301
+ const stat = statSnapshotEntry(absPath)
302
+ if (!stat.ok) return { ok: false, reason: stat.reason }
303
+
304
+ count += 1
305
+ if (count > MAX_ENTRIES) return { ok: false, reason: 'entry-limit' }
306
+
307
+ const mtimeNs = BigInt(stat.mtimeNs)
308
+ if (maxMtimeNs === null || mtimeNs > maxMtimeNs) {
309
+ maxMtimeNs = mtimeNs
310
+ }
311
+
312
+ const { ok: _statOk, ...meta } = stat
313
+ entries.push({ ...meta, relativePath: relPath })
314
+
315
+ if (stat.type === 'directory') {
316
+ let children
317
+ try {
318
+ children = readdirSync(absPath, { withFileTypes: true })
319
+ } catch {
320
+ return { ok: false, reason: 'unreadable' }
321
+ }
322
+ for (const child of children) {
323
+ const childRel =
324
+ relPath === '' ? child.name : `${relPath}/${child.name}`
325
+ const result = visit(join(absPath, child.name), childRel, depth + 1)
326
+ if (!result.ok) return result
327
+ }
328
+ }
329
+
330
+ return { ok: true }
331
+ }
332
+
333
+ const outcome = visit(candidateAbsPath, '', 0)
334
+ if (!outcome.ok) return { ok: false, reason: outcome.reason }
335
+
336
+ entries.sort((a, b) =>
337
+ a.relativePath < b.relativePath
338
+ ? -1
339
+ : a.relativePath > b.relativePath
340
+ ? 1
341
+ : 0,
342
+ )
343
+
344
+ return {
345
+ entries,
346
+ maxMtimeNs,
347
+ ok: true,
348
+ }
349
+ }
350
+
351
+ // ── Status label projection ─────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Derives a bounded status label from the CURRENT summary file, not the
355
+ * walked snapshot's cached size/type -- only identity-corroborated content
356
+ * up to MAX_SUMMARY_BYTES is ever parsed.
357
+ *
358
+ * Checks, in order: path-level lstat rejects non-regular/symlink before
359
+ * open; O_NOFOLLOW (where the platform defines it) is a second, defense-in-
360
+ * depth barrier against a symlink, not the sole protection; the opened fd's
361
+ * identity (dev/ino) must match both the pre-open lstat and the walked
362
+ * snapshot's recorded identity, or the file was replaced since the walk and
363
+ * the label is `unknown`; after the bounded read, the path is re-lstat'd
364
+ * and must still match the fd's identity.
365
+ *
366
+ * This narrows ordinary stale-state drift (a file resized, replaced, or
367
+ * symlinked between the walk and this call). It is not an atomic guarantee
368
+ * against an adversarial writer racing every check.
369
+ *
370
+ * The open also sets O_NONBLOCK (where defined): a regular file's reads are
371
+ * unaffected by this flag, but it closes one specific hang window -- an
372
+ * attacker swapping the path for a FIFO between the lstat above and this
373
+ * open would otherwise block this open indefinitely waiting for a writer.
374
+ * With O_NONBLOCK the open returns immediately regardless, and the fd's
375
+ * post-open identity/type check below then rejects it as non-regular.
376
+ * @param {string} candidateAbsPath
377
+ * @param {readonly SnapshotEntry[]} entries
378
+ * @returns {'in_progress' | 'completed' | 'degraded' | 'abnormal' | 'legacy' | 'artifactless' | 'unknown'}
379
+ */
380
+ export function deriveStatusLabel(candidateAbsPath, entries) {
381
+ const summaryEntry = entries.find((e) => e.relativePath === SUMMARY_FILE_NAME)
382
+ if (!summaryEntry) return 'artifactless'
383
+
384
+ const summaryPath = join(candidateAbsPath, SUMMARY_FILE_NAME)
385
+
386
+ let pathStat
387
+ try {
388
+ pathStat = lstatSync(summaryPath, { bigint: true })
389
+ } catch {
390
+ return 'unknown'
391
+ }
392
+ if (!pathStat.isFile()) return 'unknown'
393
+
394
+ const openFlags =
395
+ fsConstants.O_RDONLY |
396
+ (typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0) |
397
+ (typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0)
398
+
399
+ let fd
400
+ try {
401
+ fd = openSync(summaryPath, openFlags)
402
+ } catch {
403
+ return 'unknown'
404
+ }
405
+
406
+ try {
407
+ let fdStat
408
+ try {
409
+ fdStat = fstatSync(fd, { bigint: true })
410
+ } catch {
411
+ return 'unknown'
412
+ }
413
+ if (!fdStat.isFile()) return 'unknown'
414
+
415
+ const identityMatches =
416
+ fdStat.dev === pathStat.dev &&
417
+ fdStat.ino === pathStat.ino &&
418
+ fdStat.dev.toString() === summaryEntry.dev &&
419
+ fdStat.ino.toString() === summaryEntry.ino
420
+ if (!identityMatches) return 'unknown'
421
+
422
+ if (fdStat.size > BigInt(MAX_SUMMARY_BYTES)) return 'unknown'
423
+
424
+ const size = Number(fdStat.size)
425
+ const buffer = Buffer.alloc(size)
426
+ let readTotal = 0
427
+ try {
428
+ while (readTotal < buffer.length) {
429
+ const bytesRead = readSync(
430
+ fd,
431
+ buffer,
432
+ readTotal,
433
+ buffer.length - readTotal,
434
+ readTotal,
435
+ )
436
+ if (bytesRead <= 0) break
437
+ readTotal += bytesRead
438
+ }
439
+ } catch {
440
+ // A read-time failure (e.g. EIO/EBADF from the underlying storage) is
441
+ // not authoritative about run status -- report it the same as any
442
+ // other unreadable/ambiguous summary file instead of throwing out of
443
+ // this non-authoritative label projection.
444
+ return 'unknown'
445
+ }
446
+ const raw = buffer.subarray(0, readTotal).toString('utf8')
447
+
448
+ let postStat
449
+ try {
450
+ postStat = lstatSync(summaryPath, { bigint: true })
451
+ } catch {
452
+ return 'unknown'
453
+ }
454
+ if (postStat.dev !== fdStat.dev || postStat.ino !== fdStat.ino) {
455
+ return 'unknown'
456
+ }
457
+
458
+ let parsed
459
+ try {
460
+ parsed = JSON.parse(raw)
461
+ } catch {
462
+ return 'unknown'
463
+ }
464
+
465
+ if (
466
+ parsed === null ||
467
+ typeof parsed !== 'object' ||
468
+ Array.isArray(parsed)
469
+ ) {
470
+ return 'unknown'
471
+ }
472
+ if (!Object.hasOwn(parsed, 'schema_version')) return 'legacy'
473
+
474
+ const status = parsed.run_status
475
+ if (typeof status === 'string' && KNOWN_RUN_STATUSES.has(status)) {
476
+ return status
477
+ }
478
+ return 'unknown'
479
+ } finally {
480
+ closeSync(fd)
481
+ }
482
+ }
483
+
484
+ // ── Digest ───────────────────────────────────────────────────────────────
485
+
486
+ /**
487
+ * Hashes a canonical serialization of the root identity, direct-child
488
+ * inventory, and every selected candidate's full sorted metadata. Aggregate
489
+ * max-mtime/count alone would miss same-count replacements and changes
490
+ * beneath an unchanged maximum timestamp, so the full snapshot is bound.
491
+ * @param {{ dev: string, ino: string }} rootIdentity
492
+ * @param {readonly { name: string, type: string }[]} directChildren
493
+ * @param {readonly { name: string, entries: readonly SnapshotEntry[] }[]} selectedCandidates
494
+ * @returns {string}
495
+ */
496
+ export function computeSnapshotDigest(
497
+ rootIdentity,
498
+ directChildren,
499
+ selectedCandidates,
500
+ ) {
501
+ const structure = {
502
+ directChildren: [...directChildren]
503
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
504
+ .map((c) => ({ name: c.name, type: c.type })),
505
+ root: rootIdentity,
506
+ selectedCandidates: [...selectedCandidates]
507
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
508
+ .map((c) => ({ entries: c.entries, name: c.name })),
509
+ v: TOKEN_VERSION,
510
+ }
511
+ return createHash('sha256')
512
+ .update(JSON.stringify(structure), 'utf8')
513
+ .digest('hex')
514
+ }
515
+
516
+ // ── Shared scan core (used by both preview and execute) ────────────────────
517
+
518
+ /**
519
+ * @typedef {{
520
+ * ok: true,
521
+ * rootIdentity: { dev: string, ino: string },
522
+ * directChildren: { name: string, type: string }[],
523
+ * selected: { name: string, label: string, lastModifiedMs: number, entries: SnapshotEntry[] }[],
524
+ * excludedRecent: { name: string, label: string, lastModifiedMs: number }[],
525
+ * skippedUnknownUnsafe: { name: string, reason: string }[],
526
+ * } | { ok: false, category: string }} ScanResult
527
+ */
528
+
529
+ /**
530
+ * Enumerates direct children of an already-resolved, already-safety-checked
531
+ * review root and classifies every candidate directory by age and status.
532
+ * Both `runPreview` and `runExecute` call this so their selection logic can
533
+ * never drift apart; `runExecute` passes the token's fixed `cutoffTimeMs`
534
+ * and `referenceTimeMs` instead of the current wall clock, so elapsed real
535
+ * time cannot silently expand or shrink the approved set -- any actual
536
+ * difference in the underlying tree shows up as a digest mismatch instead.
537
+ * @param {string} reviewRoot
538
+ * @param {number} cutoffTimeMs
539
+ * @param {number} referenceTimeMs
540
+ * @returns {ScanResult}
541
+ */
542
+ function scanReviewRoot(reviewRoot, cutoffTimeMs, referenceTimeMs) {
543
+ let rootStat
544
+ let directEntries
545
+ try {
546
+ rootStat = lstatSync(reviewRoot, { bigint: true })
547
+ directEntries = readdirSync(reviewRoot, { withFileTypes: true })
548
+ } catch {
549
+ return { category: CATEGORY.ROOT_ENUMERATION_FAILED, ok: false }
550
+ }
551
+
552
+ // Comparisons happen in nanosecond BigInt space (see walkCandidateSubtree)
553
+ // to avoid float64 precision loss at exact-boundary inputs.
554
+ const referenceTimeNs = BigInt(referenceTimeMs) * 1_000_000n
555
+ const cutoffTimeNs = BigInt(cutoffTimeMs) * 1_000_000n
556
+
557
+ /** @type {{ name: string, type: string }[]} */
558
+ const directChildren = []
559
+ /** @type {string[]} */
560
+ const candidateDirNames = []
561
+ /** @type {{ name: string; reason: string }[]} */
562
+ const skippedUnknownUnsafe = []
563
+
564
+ for (const dirent of directEntries) {
565
+ if (dirent.isSymbolicLink()) {
566
+ directChildren.push({ name: dirent.name, type: 'symlink' })
567
+ skippedUnknownUnsafe.push({ name: dirent.name, reason: 'symlink' })
568
+ continue
569
+ }
570
+ if (dirent.isDirectory()) {
571
+ directChildren.push({ name: dirent.name, type: 'directory' })
572
+ candidateDirNames.push(dirent.name)
573
+ continue
574
+ }
575
+ // Non-directory administrative entries (e.g. .gitignore) are neither
576
+ // candidates nor reported, but still bound into the digest below.
577
+ directChildren.push({ name: dirent.name, type: 'other' })
578
+ }
579
+
580
+ /** @type {{ name: string; label: string; lastModifiedMs: number; entries: SnapshotEntry[] }[]} */
581
+ const selected = []
582
+ /** @type {{ name: string; label: string; lastModifiedMs: number }[]} */
583
+ const excludedRecent = []
584
+
585
+ for (const name of candidateDirNames) {
586
+ const candidateAbsPath = join(reviewRoot, name)
587
+ const walked = walkCandidateSubtree(candidateAbsPath)
588
+ if (!walked.ok) {
589
+ skippedUnknownUnsafe.push({ name, reason: walked.reason })
590
+ continue
591
+ }
592
+
593
+ const { maxMtimeNs } = walked
594
+ if (maxMtimeNs === null) {
595
+ skippedUnknownUnsafe.push({ name, reason: 'invalid-timestamp' })
596
+ continue
597
+ }
598
+ if (maxMtimeNs > referenceTimeNs) {
599
+ skippedUnknownUnsafe.push({ name, reason: 'future-timestamp' })
600
+ continue
601
+ }
602
+
603
+ const label = deriveStatusLabel(candidateAbsPath, walked.entries)
604
+ // Safe: epoch milliseconds (~1.7e12) are far below float64's exact
605
+ // integer range, unlike the raw nanosecond value above.
606
+ const lastModifiedMs = Number(maxMtimeNs / 1_000_000n)
607
+
608
+ if (maxMtimeNs < cutoffTimeNs) {
609
+ selected.push({ entries: walked.entries, label, lastModifiedMs, name })
610
+ } else {
611
+ excludedRecent.push({ label, lastModifiedMs, name })
612
+ }
613
+ }
614
+
615
+ return {
616
+ directChildren,
617
+ excludedRecent,
618
+ ok: true,
619
+ rootIdentity: {
620
+ dev: rootStat.dev.toString(),
621
+ ino: rootStat.ino.toString(),
622
+ },
623
+ selected,
624
+ skippedUnknownUnsafe,
625
+ }
626
+ }
627
+
628
+ // ── Preview operation ────────────────────────────────────────────────────
629
+
630
+ /**
631
+ * @typedef {{
632
+ * root: string | undefined,
633
+ * age: string | undefined,
634
+ * ackOffline: boolean,
635
+ * referenceTimeMs: number,
636
+ * }} PreviewOptions
637
+ */
638
+
639
+ /**
640
+ * @param {string} operation
641
+ * @param {string} category
642
+ * @returns {{ exitCode: number, response: Record<string, unknown> }}
643
+ */
644
+ function errorResultFor(operation, category) {
645
+ return {
646
+ exitCode: 2,
647
+ response: {
648
+ category,
649
+ operation,
650
+ result: 'error',
651
+ schema_version: SCHEMA_VERSION,
652
+ },
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Resolves a caller-supplied project root to the canonical, symlink-free
658
+ * review root, shared by preview and execute.
659
+ * @param {string | undefined} rootArg
660
+ * @returns {{ status: 'ok', path: string, canonicalProjectRoot: string } | { status: 'missing' } | { status: 'invalid-root' } | { status: 'unsafe' }}
661
+ */
662
+ function resolveReviewRoot(rootArg) {
663
+ if (!rootArg) return { status: 'invalid-root' }
664
+ let canonicalRoot
665
+ try {
666
+ canonicalRoot = realpathSync(rootArg)
667
+ if (!lstatSync(canonicalRoot).isDirectory()) {
668
+ return { status: 'invalid-root' }
669
+ }
670
+ } catch {
671
+ return { status: 'invalid-root' }
672
+ }
673
+ const chain = resolveSegmentChain(canonicalRoot, REVIEW_ROOT_SEGMENTS)
674
+ if (chain.status === 'missing') return { status: 'missing' }
675
+ if (chain.status !== 'ok') return { status: 'unsafe' }
676
+ return { canonicalProjectRoot: canonicalRoot, path: chain.path, status: 'ok' }
677
+ }
678
+
679
+ /**
680
+ * Executes the read-only preview scan. Pure with respect to time (the
681
+ * reference time is an explicit input), so tests can exercise exact-cutoff
682
+ * and future-timestamp scenarios deterministically without sleeping.
683
+ * @param {PreviewOptions} options
684
+ * @returns {{ exitCode: number, response: Record<string, unknown> }}
685
+ */
686
+ export function runPreview(options) {
687
+ if (!options.ackOffline) {
688
+ return errorResultFor('preview', CATEGORY.MISSING_ACKNOWLEDGMENT)
689
+ }
690
+
691
+ if (options.age === undefined) {
692
+ return errorResultFor('preview', CATEGORY.MISSING_AGE)
693
+ }
694
+ const ageCutoff = parseAgeCutoff(options.age)
695
+ if (!ageCutoff) {
696
+ return errorResultFor('preview', CATEGORY.INVALID_AGE)
697
+ }
698
+
699
+ if (!options.root) {
700
+ return errorResultFor('preview', CATEGORY.MISSING_ROOT)
701
+ }
702
+
703
+ const resolved = resolveReviewRoot(options.root)
704
+ if (resolved.status === 'invalid-root') {
705
+ return errorResultFor('preview', CATEGORY.INVALID_ROOT)
706
+ }
707
+ if (resolved.status === 'missing') {
708
+ return {
709
+ exitCode: 0,
710
+ response: {
711
+ operation: 'preview',
712
+ result: 'root-missing',
713
+ schema_version: SCHEMA_VERSION,
714
+ },
715
+ }
716
+ }
717
+ if (resolved.status === 'unsafe') {
718
+ return errorResultFor('preview', CATEGORY.UNSAFE_REVIEW_ROOT)
719
+ }
720
+ const reviewRoot = resolved.path
721
+
722
+ const referenceTimeMs = options.referenceTimeMs
723
+ const cutoffTimeMs = computeCutoffTimeMs(referenceTimeMs, ageCutoff.ms)
724
+ // Duration alone doesn't determine representability -- it combines with
725
+ // referenceTimeMs. Checked here, on the actual derived value, before any
726
+ // Date is constructed from it below.
727
+ if (Math.abs(cutoffTimeMs) > MAX_REPRESENTABLE_DATE_MS) {
728
+ return errorResultFor('preview', CATEGORY.INVALID_AGE)
729
+ }
730
+
731
+ const scan = scanReviewRoot(reviewRoot, cutoffTimeMs, referenceTimeMs)
732
+ if (!scan.ok) {
733
+ return errorResultFor('preview', scan.category)
734
+ }
735
+
736
+ const allNames = [
737
+ ...scan.selected.map((c) => c.name),
738
+ ...scan.excludedRecent.map((c) => c.name),
739
+ ...scan.skippedUnknownUnsafe.map((c) => c.name),
740
+ ]
741
+ const displayIds = deriveDisplayIds(allNames)
742
+
743
+ const digest = computeSnapshotDigest(
744
+ scan.rootIdentity,
745
+ scan.directChildren,
746
+ scan.selected.map((c) => ({ entries: c.entries, name: c.name })),
747
+ )
748
+
749
+ const token =
750
+ scan.selected.length > 0
751
+ ? buildPreviewToken({
752
+ ageDurationMs: ageCutoff.ms,
753
+ cutoffTimeMs,
754
+ digest,
755
+ referenceTimeMs,
756
+ })
757
+ : null
758
+
759
+ const result = scan.selected.length === 0 ? 'nothing-eligible' : 'preview'
760
+
761
+ return {
762
+ exitCode: 0,
763
+ response: {
764
+ candidates: {
765
+ excludedRecent: scan.excludedRecent.map((c) => ({
766
+ displayId: displayIds.get(c.name),
767
+ label: c.label,
768
+ lastModified: new Date(c.lastModifiedMs).toISOString(),
769
+ name: boundedName(c.name),
770
+ })),
771
+ selected: scan.selected.map((c) => ({
772
+ displayId: displayIds.get(c.name),
773
+ label: c.label,
774
+ lastModified: new Date(c.lastModifiedMs).toISOString(),
775
+ name: boundedName(c.name),
776
+ })),
777
+ skippedUnknownUnsafe: scan.skippedUnknownUnsafe.map((c) => ({
778
+ displayId: displayIds.get(c.name),
779
+ name: boundedName(c.name),
780
+ reason: c.reason,
781
+ })),
782
+ },
783
+ counts: {
784
+ excludedRecent: scan.excludedRecent.length,
785
+ selected: scan.selected.length,
786
+ skippedUnknownUnsafe: scan.skippedUnknownUnsafe.length,
787
+ },
788
+ cutoff: new Date(cutoffTimeMs).toISOString(),
789
+ operation: 'preview',
790
+ referenceTime: new Date(referenceTimeMs).toISOString(),
791
+ result,
792
+ schema_version: SCHEMA_VERSION,
793
+ token,
794
+ },
795
+ }
796
+ }
797
+
798
+ // ── Execute (deletion) token ─────────────────────────────────────────────
799
+
800
+ const MAX_TOKEN_LENGTH = 4096
801
+
802
+ /**
803
+ * Decodes and structurally validates a preview token: correct version,
804
+ * every field present with the expected type, all integers finite/safe,
805
+ * the cutoff/duration/reference-time relationship internally consistent,
806
+ * and the digest shaped like a SHA-256 hex string. Does not check the
807
+ * digest against any scan -- that happens once the review root is known.
808
+ * @param {unknown} token
809
+ * @returns {{ v: number, referenceTimeMs: number, ageDurationMs: number, cutoffTimeMs: number, digest: string } | undefined}
810
+ */
811
+ const TOKEN_FIELDS = Object.freeze([
812
+ 'v',
813
+ 'referenceTimeMs',
814
+ 'ageDurationMs',
815
+ 'cutoffTimeMs',
816
+ 'digest',
817
+ ])
818
+
819
+ export function decodeExecutionToken(token) {
820
+ if (typeof token !== 'string' || token.length === 0) return undefined
821
+ if (token.length > MAX_TOKEN_LENGTH) return undefined
822
+
823
+ let decodedBytes
824
+ try {
825
+ decodedBytes = Buffer.from(token, 'base64url')
826
+ } catch {
827
+ return undefined
828
+ }
829
+ // Buffer.from(..., 'base64url') is permissive: it silently drops
830
+ // characters outside the base64url alphabet and tolerates missing
831
+ // padding. Re-encoding the decoded bytes and requiring an exact match
832
+ // rejects any input that is not itself the canonical base64url form.
833
+ if (decodedBytes.toString('base64url') !== token) return undefined
834
+
835
+ let parsed
836
+ try {
837
+ parsed = JSON.parse(decodedBytes.toString('utf8'))
838
+ } catch {
839
+ return undefined
840
+ }
841
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
842
+ return undefined
843
+ }
844
+
845
+ // Structural guard, not authentication: reject any payload that does not
846
+ // carry exactly the fixed field set (no extra/unknown keys, none absent).
847
+ const keys = Object.keys(parsed)
848
+ if (
849
+ keys.length !== TOKEN_FIELDS.length ||
850
+ !TOKEN_FIELDS.every((field) => keys.includes(field))
851
+ ) {
852
+ return undefined
853
+ }
854
+
855
+ const { v, referenceTimeMs, ageDurationMs, cutoffTimeMs, digest } = parsed
856
+ if (v !== TOKEN_VERSION) return undefined
857
+ if (!Number.isSafeInteger(referenceTimeMs) || referenceTimeMs < 0) {
858
+ return undefined
859
+ }
860
+ if (!Number.isSafeInteger(ageDurationMs) || ageDurationMs <= 0) {
861
+ return undefined
862
+ }
863
+ if (!Number.isSafeInteger(cutoffTimeMs)) return undefined
864
+ if (cutoffTimeMs !== referenceTimeMs - ageDurationMs) return undefined
865
+ if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
866
+ return undefined
867
+ }
868
+
869
+ return { ageDurationMs, cutoffTimeMs, digest, referenceTimeMs, v }
870
+ }
871
+
872
+ // ── Execute (deletion) per-candidate primitive ──────────────────────────
873
+
874
+ /**
875
+ * Immediately before removing one previously-approved candidate, re-walks
876
+ * the fixed `.context/systematic/ce-review` segment chain from the
877
+ * canonical project root -- never trusting a previously-resolved review
878
+ * root path string, which could since traverse an ancestor symlink even
879
+ * when the review directory's own device/inode identity is unchanged
880
+ * (e.g. the whole `.context` tree relocated and a symlink left in its
881
+ * place still resolves to the same underlying directory). Also rechecks
882
+ * this candidate's entire subtree against its approved snapshot, then
883
+ * removes only that exact direct-child path -- never a path derived from
884
+ * a display name or supplied by the token.
885
+ *
886
+ * Deliberately does not re-diff the whole root's membership: a caller's
887
+ * own prior deletions earlier in the same batch legitimately change the
888
+ * root's mtime and child list, and re-checking that here would misclassify
889
+ * expected self-induced change as external drift. The ancestor chain,
890
+ * root identity (device/inode, not-a-symlink), and this one candidate's
891
+ * full metadata are the only things rechecked.
892
+ * @param {{ canonicalProjectRoot: string, rootIdentity: { dev: string, ino: string }, candidate: { name: string, entries: SnapshotEntry[] } }} input
893
+ * @returns {{ status: 'deleted' } | { status: 'skipped', reason: string } | { status: 'failed', reason: string }}
894
+ */
895
+ export function executeApprovedCandidate({
896
+ canonicalProjectRoot,
897
+ rootIdentity,
898
+ candidate,
899
+ }) {
900
+ if (
901
+ !candidate.name ||
902
+ candidate.name === '.' ||
903
+ candidate.name === '..' ||
904
+ candidate.name.includes('/') ||
905
+ // Defense-in-depth: a direct-child name must equal its own basename
906
+ // under the platform's own path semantics (the same module `join`
907
+ // above uses) -- catches any other single-segment-violating name
908
+ // without a hardcoded platform ban and without rejecting legitimate
909
+ // names containing a literal backslash character on this platform.
910
+ basename(candidate.name) !== candidate.name
911
+ ) {
912
+ return { reason: 'refused-unsafe-name', status: 'failed' }
913
+ }
914
+
915
+ const chain = resolveSegmentChain(canonicalProjectRoot, REVIEW_ROOT_SEGMENTS)
916
+ if (chain.status !== 'ok') {
917
+ return { reason: 'root-identity-changed', status: 'failed' }
918
+ }
919
+ const reviewRoot = chain.path
920
+
921
+ let freshRootStat
922
+ try {
923
+ freshRootStat = lstatSync(reviewRoot, { bigint: true })
924
+ } catch {
925
+ return { reason: 'root-unavailable', status: 'failed' }
926
+ }
927
+ if (
928
+ freshRootStat.isSymbolicLink() ||
929
+ freshRootStat.dev.toString() !== rootIdentity.dev ||
930
+ freshRootStat.ino.toString() !== rootIdentity.ino
931
+ ) {
932
+ return { reason: 'root-identity-changed', status: 'failed' }
933
+ }
934
+
935
+ const candidateAbsPath = join(reviewRoot, candidate.name)
936
+ const rewalked = walkCandidateSubtree(candidateAbsPath)
937
+ if (!rewalked.ok) {
938
+ return { reason: 'drift-detected', status: 'skipped' }
939
+ }
940
+ if (JSON.stringify(rewalked.entries) !== JSON.stringify(candidate.entries)) {
941
+ return { reason: 'drift-detected', status: 'skipped' }
942
+ }
943
+
944
+ try {
945
+ rmSync(candidateAbsPath, { recursive: true })
946
+ } catch {
947
+ return { reason: 'deletion-failed', status: 'failed' }
948
+ }
949
+
950
+ return { status: 'deleted' }
951
+ }
952
+
953
+ // ── Execute (deletion) operation ─────────────────────────────────────────
954
+
955
+ /**
956
+ * @typedef {{
957
+ * root: string | undefined,
958
+ * ackOffline: boolean,
959
+ * token: string | undefined,
960
+ * nowMs: number,
961
+ * }} ExecuteOptions
962
+ */
963
+
964
+ /**
965
+ * @returns {{ exitCode: number, response: Record<string, unknown> }}
966
+ */
967
+ function previewStaleResult() {
968
+ return {
969
+ exitCode: 3,
970
+ response: {
971
+ operation: 'execute',
972
+ result: 'preview-stale',
973
+ schema_version: SCHEMA_VERSION,
974
+ },
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Validates the token and offline acknowledgment, rescans the review root
980
+ * using the token's fixed time boundary (never the current wall clock),
981
+ * and -- only if the fresh digest still matches the token -- deletes each
982
+ * originally-selected candidate via {@link executeApprovedCandidate}.
983
+ *
984
+ * `nowMs` is used only to reject a token whose reference time is in the
985
+ * future; it never substitutes for the token's own fixed reference time in
986
+ * the rescan itself, so elapsed real time cannot silently expand the
987
+ * approved set.
988
+ * @param {ExecuteOptions} options
989
+ * @returns {{ exitCode: number, response: Record<string, unknown> }}
990
+ */
991
+ export function runExecute(options) {
992
+ if (!options.ackOffline) {
993
+ return errorResultFor('execute', CATEGORY.MISSING_ACKNOWLEDGMENT)
994
+ }
995
+ if (options.token === undefined) {
996
+ return errorResultFor('execute', CATEGORY.MISSING_TOKEN)
997
+ }
998
+ const decoded = decodeExecutionToken(options.token)
999
+ if (!decoded) {
1000
+ return errorResultFor('execute', CATEGORY.INVALID_TOKEN)
1001
+ }
1002
+ if (decoded.referenceTimeMs > options.nowMs) {
1003
+ return errorResultFor('execute', CATEGORY.INVALID_TOKEN)
1004
+ }
1005
+
1006
+ if (!options.root) {
1007
+ return errorResultFor('execute', CATEGORY.MISSING_ROOT)
1008
+ }
1009
+
1010
+ const resolved = resolveReviewRoot(options.root)
1011
+ if (resolved.status === 'invalid-root') {
1012
+ return errorResultFor('execute', CATEGORY.INVALID_ROOT)
1013
+ }
1014
+ if (resolved.status === 'missing') {
1015
+ // The review root existed at preview time (a token was issued) but is
1016
+ // gone now: membership changed, not a fresh no-op.
1017
+ return previewStaleResult()
1018
+ }
1019
+ if (resolved.status === 'unsafe') {
1020
+ return errorResultFor('execute', CATEGORY.UNSAFE_REVIEW_ROOT)
1021
+ }
1022
+ const reviewRoot = resolved.path
1023
+ const canonicalProjectRoot = resolved.canonicalProjectRoot
1024
+
1025
+ const scan = scanReviewRoot(
1026
+ reviewRoot,
1027
+ decoded.cutoffTimeMs,
1028
+ decoded.referenceTimeMs,
1029
+ )
1030
+ if (!scan.ok) {
1031
+ return errorResultFor('execute', scan.category)
1032
+ }
1033
+
1034
+ const digest = computeSnapshotDigest(
1035
+ scan.rootIdentity,
1036
+ scan.directChildren,
1037
+ scan.selected.map((c) => ({ entries: c.entries, name: c.name })),
1038
+ )
1039
+ if (digest !== decoded.digest) {
1040
+ return previewStaleResult()
1041
+ }
1042
+
1043
+ /** @type {{ name: string }[]} */
1044
+ const deleted = []
1045
+ /** @type {{ name: string; reason: string }[]} */
1046
+ const skipped = []
1047
+ /** @type {{ name: string; reason: string }[]} */
1048
+ const failed = []
1049
+
1050
+ for (const candidate of scan.selected) {
1051
+ const outcome = executeApprovedCandidate({
1052
+ candidate,
1053
+ canonicalProjectRoot,
1054
+ rootIdentity: scan.rootIdentity,
1055
+ })
1056
+ if (outcome.status === 'deleted') {
1057
+ deleted.push({ name: candidate.name })
1058
+ } else if (outcome.status === 'skipped') {
1059
+ skipped.push({ name: candidate.name, reason: outcome.reason })
1060
+ } else {
1061
+ failed.push({ name: candidate.name, reason: outcome.reason })
1062
+ }
1063
+ }
1064
+
1065
+ const allNames = [
1066
+ ...scan.selected.map((c) => c.name),
1067
+ ...scan.excludedRecent.map((c) => c.name),
1068
+ ...scan.skippedUnknownUnsafe.map((c) => c.name),
1069
+ ]
1070
+ const displayIds = deriveDisplayIds(allNames)
1071
+
1072
+ const partial = skipped.length > 0 || failed.length > 0
1073
+
1074
+ return {
1075
+ exitCode: partial ? 1 : 0,
1076
+ response: {
1077
+ candidates: {
1078
+ deleted: deleted.map((c) => ({
1079
+ displayId: displayIds.get(c.name),
1080
+ name: boundedName(c.name),
1081
+ })),
1082
+ excludedRecent: scan.excludedRecent.map((c) => ({
1083
+ displayId: displayIds.get(c.name),
1084
+ label: c.label,
1085
+ lastModified: new Date(c.lastModifiedMs).toISOString(),
1086
+ name: boundedName(c.name),
1087
+ })),
1088
+ failed: failed.map((c) => ({
1089
+ displayId: displayIds.get(c.name),
1090
+ name: boundedName(c.name),
1091
+ reason: c.reason,
1092
+ })),
1093
+ skipped: skipped.map((c) => ({
1094
+ displayId: displayIds.get(c.name),
1095
+ name: boundedName(c.name),
1096
+ reason: c.reason,
1097
+ })),
1098
+ skippedUnknownUnsafe: scan.skippedUnknownUnsafe.map((c) => ({
1099
+ displayId: displayIds.get(c.name),
1100
+ name: boundedName(c.name),
1101
+ reason: c.reason,
1102
+ })),
1103
+ },
1104
+ counts: {
1105
+ deleted: deleted.length,
1106
+ excludedRecent: scan.excludedRecent.length,
1107
+ failed: failed.length,
1108
+ selected: scan.selected.length,
1109
+ skipped: skipped.length,
1110
+ skippedUnknownUnsafe: scan.skippedUnknownUnsafe.length,
1111
+ },
1112
+ operation: 'execute',
1113
+ result: partial ? 'partial' : 'deleted',
1114
+ schema_version: SCHEMA_VERSION,
1115
+ },
1116
+ }
1117
+ }
1118
+
1119
+ // ── CLI entry point ──────────────────────────────────────────────────────
1120
+
1121
+ /**
1122
+ * Strictly parses `preview`'s fixed argument set: only `--root <value>`,
1123
+ * `--age <value>`, and the boolean `--ack-offline` are recognized. Any
1124
+ * unknown flag, unexpected positional token, duplicate flag, a flag
1125
+ * missing its value, or a value that is itself another recognized flag
1126
+ * (e.g. `--root --age`) is rejected outright, before any filesystem scan
1127
+ * begins. Absence of a flag is not an error here -- `runPreview` reports
1128
+ * the specific `missing-root-argument`/`missing-age`/
1129
+ * `missing-acknowledgment` diagnostics for that.
1130
+ * @param {readonly string[]} args
1131
+ * @returns {{ ok: true, root: string | undefined, age: string | undefined, ackOffline: boolean } | { ok: false }}
1132
+ */
1133
+ function parsePreviewArgs(args) {
1134
+ let root
1135
+ let age
1136
+ let ackOffline = false
1137
+ let rootSeen = false
1138
+ let ageSeen = false
1139
+ let ackSeen = false
1140
+
1141
+ for (let i = 0; i < args.length; i += 1) {
1142
+ const arg = args[i]
1143
+ if (arg === '--root' || arg === '--age') {
1144
+ const seen = arg === '--root' ? rootSeen : ageSeen
1145
+ if (seen) return { ok: false }
1146
+ const value = args[i + 1]
1147
+ if (value === undefined || value.startsWith('--')) return { ok: false }
1148
+ if (arg === '--root') {
1149
+ root = value
1150
+ rootSeen = true
1151
+ } else {
1152
+ age = value
1153
+ ageSeen = true
1154
+ }
1155
+ i += 1
1156
+ continue
1157
+ }
1158
+ if (arg === '--ack-offline') {
1159
+ if (ackSeen) return { ok: false }
1160
+ ackSeen = true
1161
+ ackOffline = true
1162
+ continue
1163
+ }
1164
+ return { ok: false }
1165
+ }
1166
+
1167
+ return { ackOffline, age, ok: true, root }
1168
+ }
1169
+
1170
+ /**
1171
+ * Strictly parses `execute`'s fixed argument set: only `--root <value>`,
1172
+ * `--token <value>`, and the boolean `--ack-offline` are recognized. Any
1173
+ * unknown flag, a duplicate flag, or a flag missing its value is rejected
1174
+ * outright -- there is no `--force` or other extra deletion path.
1175
+ * @param {readonly string[]} args
1176
+ * @returns {{ ok: true, root: string | undefined, token: string | undefined, ackOffline: boolean } | { ok: false }}
1177
+ */
1178
+ function parseExecuteArgs(args) {
1179
+ let root
1180
+ let token
1181
+ let ackOffline = false
1182
+ let rootSeen = false
1183
+ let tokenSeen = false
1184
+ let ackSeen = false
1185
+
1186
+ for (let i = 0; i < args.length; i += 1) {
1187
+ const arg = args[i]
1188
+ if (arg === '--root' || arg === '--token') {
1189
+ const seen = arg === '--root' ? rootSeen : tokenSeen
1190
+ if (seen) return { ok: false }
1191
+ const value = args[i + 1]
1192
+ if (value === undefined || value.startsWith('--')) return { ok: false }
1193
+ if (arg === '--root') {
1194
+ root = value
1195
+ rootSeen = true
1196
+ } else {
1197
+ token = value
1198
+ tokenSeen = true
1199
+ }
1200
+ i += 1
1201
+ continue
1202
+ }
1203
+ if (arg === '--ack-offline') {
1204
+ if (ackSeen) return { ok: false }
1205
+ ackSeen = true
1206
+ ackOffline = true
1207
+ continue
1208
+ }
1209
+ return { ok: false }
1210
+ }
1211
+
1212
+ return { ackOffline, ok: true, root, token }
1213
+ }
1214
+
1215
+ // ── Static help (no scan, no state, no deletion) ────────────────────────
1216
+
1217
+ const HELP_COMMANDS = Object.freeze({
1218
+ execute: Object.freeze({
1219
+ description:
1220
+ 'Deletes only the exact candidates approved by an unexpired preview token. Rescans the whole selection and each individual candidate immediately before deleting it, and refuses on any drift.',
1221
+ flags: Object.freeze([
1222
+ '--root <path> (required) The same project root the preview token was issued against.',
1223
+ '--ack-offline (required) Operator assertion that this run is offline; not independently verified by this tool.',
1224
+ '--token <token> (required) The bounded token returned by a prior preview.',
1225
+ ]),
1226
+ usage: 'cleanup.mjs execute --root <path> --ack-offline --token <token>',
1227
+ }),
1228
+ preview: Object.freeze({
1229
+ description:
1230
+ 'Read-only preview of historical ce:review run directories older than an age cutoff. Never mutates the filesystem.',
1231
+ flags: Object.freeze([
1232
+ '--root <path> (required) Project root containing .context/systematic/ce-review.',
1233
+ '--age <N|Nd|Nw> (required) Positive integer age cutoff in days (default) or weeks (w suffix).',
1234
+ '--ack-offline (required) Operator assertion that this run is offline; not independently verified by this tool.',
1235
+ ]),
1236
+ usage: 'cleanup.mjs preview --root <path> --age <N|Nd|Nw> --ack-offline',
1237
+ }),
1238
+ })
1239
+
1240
+ const HELP_EXIT_CODES = Object.freeze({
1241
+ 0: 'Preview complete, all approved deletions completed, or an absent review root (no-op).',
1242
+ 1: 'Partial: at least one approved candidate was skipped (drift detected) or failed (deletion error) during execute; other candidates in the same run still completed.',
1243
+ 2: 'Invalid, ambiguous, or unrecognized arguments; missing offline acknowledgment; or an unsafe/invalid root -- no scan or deletion begins.',
1244
+ 3: 'preview-stale: the execute-time rescan no longer matches the preview token (changed root, membership, or candidate). Zero deletions; a renewed preview and approval are required.',
1245
+ })
1246
+
1247
+ const HELP_NOTES = Object.freeze([
1248
+ '--ack-offline is an operator assertion that this invocation is running offline; it is not independently verified by this tool.',
1249
+ 'A valid --token for execute proves the rescanned tree still matches the earlier preview snapshot. It is not authenticated human approval -- the caller must still ask the operator to confirm deletion separately.',
1250
+ ])
1251
+
1252
+ /**
1253
+ * Builds the static, read-only help payload. Never touches the filesystem,
1254
+ * never reads `--root`/`--age`/`--ack-offline`/`--token`, and never reveals
1255
+ * the invoking process's cwd or environment.
1256
+ * @param {'help' | 'preview' | 'execute'} scope
1257
+ * @returns {Record<string, unknown>}
1258
+ */
1259
+ function buildHelpResponse(scope) {
1260
+ if (scope === 'help') {
1261
+ return {
1262
+ commands: HELP_COMMANDS,
1263
+ exitCodes: HELP_EXIT_CODES,
1264
+ notes: HELP_NOTES,
1265
+ operation: 'help',
1266
+ result: 'help',
1267
+ schema_version: SCHEMA_VERSION,
1268
+ usage: {
1269
+ execute: HELP_COMMANDS.execute.usage,
1270
+ preview: HELP_COMMANDS.preview.usage,
1271
+ },
1272
+ }
1273
+ }
1274
+ return {
1275
+ ...HELP_COMMANDS[scope],
1276
+ exitCodes: HELP_EXIT_CODES,
1277
+ notes: HELP_NOTES,
1278
+ operation: scope,
1279
+ result: 'help',
1280
+ schema_version: SCHEMA_VERSION,
1281
+ }
1282
+ }
1283
+
1284
+ function emitHelpAndExit(scope) {
1285
+ process.stdout.write(`${JSON.stringify(buildHelpResponse(scope))}\n`)
1286
+ process.exit(0)
1287
+ }
1288
+
1289
+ function main() {
1290
+ const argv = process.argv.slice(2)
1291
+ const operation = argv[0]
1292
+ const rest = argv.slice(1)
1293
+
1294
+ if ((operation === 'help' || operation === '--help') && rest.length === 0) {
1295
+ emitHelpAndExit('help')
1296
+ return
1297
+ }
1298
+
1299
+ if (operation === 'execute') {
1300
+ if (rest.length === 1 && rest[0] === '--help') {
1301
+ emitHelpAndExit('execute')
1302
+ return
1303
+ }
1304
+ const parsedArgs = parseExecuteArgs(rest)
1305
+ if (!parsedArgs.ok) {
1306
+ emitAndExit(
1307
+ errorResultFor('execute', CATEGORY.INVALID_ARGUMENTS),
1308
+ 'execute',
1309
+ )
1310
+ return
1311
+ }
1312
+ const outcome = runExecute({
1313
+ ackOffline: parsedArgs.ackOffline,
1314
+ nowMs: Date.now(),
1315
+ root: parsedArgs.root,
1316
+ token: parsedArgs.token,
1317
+ })
1318
+ process.stdout.write(`${JSON.stringify(outcome.response)}\n`)
1319
+ process.exit(outcome.exitCode)
1320
+ return
1321
+ }
1322
+
1323
+ if (operation !== 'preview') {
1324
+ // Never echo the caller-supplied operation text back into the response:
1325
+ // it is unbounded, attacker-controlled input and this is an error path
1326
+ // that must stay within the fixed-vocabulary output contract.
1327
+ emitAndExit(
1328
+ errorResultFor('preview', CATEGORY.UNKNOWN_OPERATION),
1329
+ 'unknown',
1330
+ )
1331
+ return
1332
+ }
1333
+
1334
+ if (rest.length === 1 && rest[0] === '--help') {
1335
+ emitHelpAndExit('preview')
1336
+ return
1337
+ }
1338
+
1339
+ const parsedArgs = parsePreviewArgs(rest)
1340
+ if (!parsedArgs.ok) {
1341
+ emitAndExit(
1342
+ errorResultFor('preview', CATEGORY.INVALID_ARGUMENTS),
1343
+ 'preview',
1344
+ )
1345
+ return
1346
+ }
1347
+
1348
+ const outcome = runPreview({
1349
+ ackOffline: parsedArgs.ackOffline,
1350
+ age: parsedArgs.age,
1351
+ referenceTimeMs: Date.now(),
1352
+ root: parsedArgs.root,
1353
+ })
1354
+ process.stdout.write(`${JSON.stringify(outcome.response)}\n`)
1355
+ process.exit(outcome.exitCode)
1356
+ }
1357
+
1358
+ /**
1359
+ * Top-level error boundary around {@link main}. Any exception that escapes
1360
+ * the operation handlers (e.g. an unexpected filesystem error surfacing
1361
+ * through a non-authoritative read path) is caught here so the process
1362
+ * still emits the one fixed, bounded JSON object on stdout with a defined
1363
+ * exit code -- never a raw Node stack trace (which would include absolute
1364
+ * paths) on stderr, and never a silent success/deletion. The underlying
1365
+ * error's message and stack are deliberately never read or emitted.
1366
+ */
1367
+ function runMain() {
1368
+ try {
1369
+ main()
1370
+ } catch {
1371
+ emitAndExit(errorResultFor('unknown', CATEGORY.INTERNAL_ERROR), 'unknown')
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * @param {{ exitCode: number, response: Record<string, unknown> }} outcome
1377
+ * @param {string | undefined} operation
1378
+ */
1379
+ function emitAndExit(outcome, operation) {
1380
+ const response = { ...outcome.response, operation: operation ?? null }
1381
+ process.stdout.write(`${JSON.stringify(response)}\n`)
1382
+ process.exit(outcome.exitCode)
1383
+ }
1384
+
1385
+ const isDirectInvocation =
1386
+ process.argv[1] !== undefined &&
1387
+ import.meta.url === pathToFileURL(process.argv[1]).href
1388
+
1389
+ if (isDirectInvocation) {
1390
+ runMain()
1391
+ }