@crewhaus/tool-lifecycle 0.7.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,264 @@
1
+ /**
2
+ * `StoreMigrate`'s half: what a harness's store version IS, what the
3
+ * migration path actually carries, and what it leaves behind.
4
+ *
5
+ * The copy itself is `@crewhaus/harness-lifecycle`'s `runRetentionExport` —
6
+ * the same enumerate-and-copy code the retention export verb uses, which
7
+ * copies session and audit files VERBATIM (an audit day file has to keep its
8
+ * exact bytes or the hash chain over it stops verifying). Nothing about
9
+ * selection or copying is re-implemented here.
10
+ *
11
+ * What is here is the part that makes a migration honest rather than a hope:
12
+ *
13
+ * - THE STORE VERSION. Read, reported, and never quietly changed. This
14
+ * build can move a store; it cannot rewrite records into a new shape,
15
+ * because the memory-entry 1→2 transform lives in `apps/cli`
16
+ * (`migrateMemories`) and reaching it needs packages this one does not
17
+ * depend on. A version-changing migration is REFUSED by name instead of
18
+ * being silently performed as a copy, which would leave a caller
19
+ * believing their store was upgraded.
20
+ * - WHAT IS NOT CARRIED. The export path covers `.crewhaus/sessions` and
21
+ * `.crewhaus/audit`. Memories, prompts, graders, the registry and the
22
+ * policy files are NOT part of it. A migration that reports success while
23
+ * leaving them behind is exactly the half-migration that puts a store in
24
+ * neither shape, so everything under `.crewhaus` that the copy does not
25
+ * carry is enumerated and returned.
26
+ * - THE RECEIPT. Every file that was copied, with its sha256, written to
27
+ * the destination. A re-run reads it back, so "already migrated" and
28
+ * "migrated just now" are distinguishable, and an interrupted run can be
29
+ * resumed by running it again.
30
+ */
31
+ import { lstatSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
32
+ import * as path from "node:path";
33
+ import { fail, renderPath } from "./result";
34
+ /**
35
+ * The two directories under `.crewhaus` that ARE the harness's record store:
36
+ * the pair `runRetentionExport` carries, and the same pair
37
+ * `openHarnessRecordStore` enumerates and a sweep deletes from. Mirrored here
38
+ * because `@crewhaus/harness-lifecycle` keeps the relpaths private.
39
+ */
40
+ export const COVERED_STORE_DIRS = ["sessions", "audit"];
41
+ export const RECEIPT_FILENAME = "store-migration.json";
42
+ /**
43
+ * The file `runRetentionExport` writes into its output directory on every
44
+ * REAL run, on top of the copies themselves.
45
+ *
46
+ * Mirrored here (the package does not export the name) because it is a
47
+ * destination this tool writes but does not copy: leaving it out of the
48
+ * written set made the conflict check miss it, so a destination that already
49
+ * held a `manifest.json` — a previous `crewhaus retention export`, say — had
50
+ * it overwritten without `overwrite` being asked for and without the preview
51
+ * ever naming it. `index.test.ts` pins the coupling: a real migration must
52
+ * produce this file at this name.
53
+ */
54
+ export const EXPORT_MANIFEST_FILENAME = "manifest.json";
55
+ export const RECORD_SHAPE_MIGRATION_UNAVAILABLE = "a version-changing migration rewrites every record, and the record transforms live in apps/cli " +
56
+ "(migrateMemories, over @crewhaus/memory-store + @crewhaus/migration-engine) which " +
57
+ "@crewhaus/tool-lifecycle does not depend on. This tool moves a store between roots at the " +
58
+ "version it already has; it will not claim to have upgraded one.";
59
+ /**
60
+ * Read `<harnessDir>/.crewhaus/meta.json`, the file `crewhaus migrate
61
+ * memories` stamps (`{ memories: { schemaVersion, migratedAt } }`).
62
+ *
63
+ * An unreadable or malformed meta file is `unknown`, never version 1: a
64
+ * migration that guesses the version it is migrating FROM is a migration that
65
+ * rewrites records twice.
66
+ */
67
+ export function readStoreVersion(harnessDirAbs) {
68
+ const metaPath = path.join(harnessDirAbs, ".crewhaus", "meta.json");
69
+ let raw;
70
+ try {
71
+ raw = readFileSync(metaPath, "utf8");
72
+ }
73
+ catch (err) {
74
+ const code = err.code;
75
+ if (code === "ENOENT")
76
+ return { state: "unstamped" };
77
+ return {
78
+ state: "unknown",
79
+ reason: `.crewhaus/meta.json could not be read (${code ?? "unknown error"})`,
80
+ };
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(raw);
85
+ }
86
+ catch (err) {
87
+ return {
88
+ state: "unknown",
89
+ reason: `.crewhaus/meta.json is not valid JSON (${err.message})`,
90
+ };
91
+ }
92
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
93
+ return { state: "unknown", reason: ".crewhaus/meta.json is not a JSON object" };
94
+ }
95
+ const memories = parsed["memories"];
96
+ if (memories === undefined)
97
+ return { state: "unstamped" };
98
+ if (typeof memories !== "object" || memories === null || Array.isArray(memories)) {
99
+ return {
100
+ state: "unknown",
101
+ reason: '.crewhaus/meta.json has a "memories" field that is not an object',
102
+ };
103
+ }
104
+ const version = memories["schemaVersion"];
105
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
106
+ return {
107
+ state: "unknown",
108
+ reason: `.crewhaus/meta.json has memories.schemaVersion ${JSON.stringify(version)}, which is not a positive integer`,
109
+ };
110
+ }
111
+ return { state: "known", memoriesSchemaVersion: version };
112
+ }
113
+ /**
114
+ * Everything directly under `.crewhaus` that the export path does not copy.
115
+ *
116
+ * Returned so the result can name it. A caller who migrates a store and is
117
+ * told only "42 sessions, 3 audit days" will believe the memories came too.
118
+ */
119
+ export function uncoveredStateEntries(harnessDirAbs) {
120
+ const stateDir = path.join(harnessDirAbs, ".crewhaus");
121
+ let names;
122
+ try {
123
+ names = readdirSync(stateDir).sort();
124
+ }
125
+ catch (err) {
126
+ const code = err.code;
127
+ if (code === "ENOENT")
128
+ return { ok: true, value: [] };
129
+ return fail("unreadable", `.crewhaus could not be listed (${code ?? "unknown error"})`);
130
+ }
131
+ const covered = new Set(COVERED_STORE_DIRS);
132
+ return {
133
+ ok: true,
134
+ value: names
135
+ .filter((n) => !covered.has(n))
136
+ .map((n) => ({
137
+ entry: `.crewhaus/${n}`,
138
+ reason: "not part of the store export path (@crewhaus/harness-lifecycle carries .crewhaus/sessions and .crewhaus/audit only)",
139
+ })),
140
+ };
141
+ }
142
+ /**
143
+ * What is already at the destination, and whether a previous migration put it
144
+ * there. A receipt naming a DIFFERENT source is the case that matters: two
145
+ * harnesses' stores merged into one directory cannot be told apart
146
+ * afterwards, and the audit chains cannot both be valid.
147
+ */
148
+ export function inspectDestination(destAbs, plannedRelPaths) {
149
+ let exists = true;
150
+ try {
151
+ if (!statSync(destAbs).isDirectory()) {
152
+ return fail("not-a-directory", `"${renderPath(destAbs)}" exists and is not a directory`);
153
+ }
154
+ }
155
+ catch (err) {
156
+ const code = err.code;
157
+ if (code === "ENOENT")
158
+ exists = false;
159
+ else
160
+ return fail("unreadable", `the destination could not be read (${code ?? "unknown error"})`);
161
+ }
162
+ const conflicts = [];
163
+ if (exists) {
164
+ for (const rel of plannedRelPaths) {
165
+ try {
166
+ // lstat, not stat: the question is whether the NAME is taken, not
167
+ // whether it leads anywhere. `stat` follows a symlink and reports a
168
+ // DANGLING one as absent — so a link left at a destination path read
169
+ // as "nothing here", `overwrite` was never asked for, and the copy
170
+ // went through the link to whatever it pointed at.
171
+ lstatSync(path.join(destAbs, ...rel.split("/")));
172
+ conflicts.push(rel);
173
+ }
174
+ catch (err) {
175
+ // ENOENT is the normal case: nothing is there. Anything else means
176
+ // the entry could not be identified, which is not the same as absent
177
+ // and still counts as "something is in the way".
178
+ if (err.code !== "ENOENT")
179
+ conflicts.push(rel);
180
+ }
181
+ }
182
+ }
183
+ const receiptPath = path.join(destAbs, RECEIPT_FILENAME);
184
+ let receiptRaw;
185
+ try {
186
+ receiptRaw = readFileSync(receiptPath, "utf8");
187
+ }
188
+ catch (err) {
189
+ const code = err.code;
190
+ if (code !== "ENOENT") {
191
+ return {
192
+ ok: true,
193
+ value: {
194
+ exists,
195
+ conflicts,
196
+ receiptProblem: `a ${RECEIPT_FILENAME} is present but could not be read (${code ?? "unknown error"})`,
197
+ },
198
+ };
199
+ }
200
+ }
201
+ if (receiptRaw === undefined)
202
+ return { ok: true, value: { exists, conflicts } };
203
+ try {
204
+ const parsed = JSON.parse(receiptRaw);
205
+ if (typeof parsed?.sourceRoot !== "string") {
206
+ return {
207
+ ok: true,
208
+ value: {
209
+ exists,
210
+ conflicts,
211
+ receiptProblem: `${RECEIPT_FILENAME} does not name a source root`,
212
+ },
213
+ };
214
+ }
215
+ return { ok: true, value: { exists, conflicts, receipt: parsed } };
216
+ }
217
+ catch (err) {
218
+ return {
219
+ ok: true,
220
+ value: {
221
+ exists,
222
+ conflicts,
223
+ receiptProblem: `${RECEIPT_FILENAME} is not valid JSON (${err.message})`,
224
+ },
225
+ };
226
+ }
227
+ }
228
+ /** The destination-relative paths a migration of this selection would write. */
229
+ export function plannedPaths(opts) {
230
+ const rels = [];
231
+ for (const id of opts.sessions) {
232
+ rels.push(`sessions/${id}.json`);
233
+ if (opts.withEventLog.has(id))
234
+ rels.push(`sessions/${id}.jsonl`);
235
+ }
236
+ for (const day of opts.auditDays)
237
+ rels.push(`audit/${day}.jsonl`);
238
+ if (opts.chainTail)
239
+ rels.push("audit/_chain-tail.json");
240
+ return rels;
241
+ }
242
+ /**
243
+ * Every destination-relative path a REAL migration writes: the copies, plus
244
+ * the export's own `manifest.json` and this tool's receipt.
245
+ *
246
+ * Distinct from {@link plannedPaths} on purpose. `plannedPaths` is the set
247
+ * that is copied from a source and can therefore be hashed on both sides;
248
+ * this is the set that is WRITTEN, which is what the conflict check and the
249
+ * containment check have to be asked about. Reporting the first as the second
250
+ * is how a destination file nobody copied gets destroyed quietly.
251
+ */
252
+ export function writtenPaths(plannedRelPaths) {
253
+ return [...plannedRelPaths, EXPORT_MANIFEST_FILENAME, RECEIPT_FILENAME];
254
+ }
255
+ export function writeReceipt(destAbs, receipt) {
256
+ const p = path.join(destAbs, RECEIPT_FILENAME);
257
+ try {
258
+ writeFileSync(p, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
259
+ return { ok: true, value: p };
260
+ }
261
+ catch (err) {
262
+ return fail("unreadable", `the migration receipt could not be written: ${err.message}`);
263
+ }
264
+ }
@@ -0,0 +1,72 @@
1
+ import { type SafePath } from "../paths";
2
+ /** Compact JSON — the reader is a model, and every byte is context. */
3
+ export declare const json: (value: unknown) => string;
4
+ /** A caller-supplied path, safe to put in a message: bounded and printable. */
5
+ export declare function renderPath(given: string): string;
6
+ /**
7
+ * Why something could not be loaded.
8
+ *
9
+ * `missing` is the only code a caller may carry on past ("there is no store
10
+ * here"); every other one is a refusal that must be returned. It is a field
11
+ * rather than something sniffed out of the message so that rewording a
12
+ * message can never silently turn a refusal into a carry-on.
13
+ */
14
+ export type FailCode = "refused" | "missing" | "not-a-directory" | "bad-input" | "unreadable" | "too-large";
15
+ export type Failure = {
16
+ readonly ok: false;
17
+ readonly reason: string;
18
+ readonly code: FailCode;
19
+ };
20
+ export type Loaded<T> = {
21
+ readonly ok: true;
22
+ readonly value: T;
23
+ } | Failure;
24
+ export declare function fail(code: FailCode, reason: string): Failure;
25
+ /** The JSON a tool returns when it will not proceed. */
26
+ export declare function refusal(tool: string, code: FailCode, reason: string, extra?: {}): string;
27
+ /**
28
+ * Resolve a caller-supplied path inside the workspace.
29
+ *
30
+ * The NUL guard is here rather than in `paths.ts` (which is a verbatim copy
31
+ * of `@crewhaus/tool-pkg`'s, deliberately): a NUL truncates a path at the
32
+ * syscall boundary, so `a\u0000/../../etc` can resolve one way in the
33
+ * containment check and another way in the `open` that follows. Refused at
34
+ * the gate instead of surfacing later as "unreadable".
35
+ */
36
+ export declare function contain(toolName: string, rel: string): Loaded<SafePath>;
37
+ /**
38
+ * Contain every path an operation will WRITE, not just the directory it was
39
+ * handed.
40
+ *
41
+ * `contain` resolves the ROOT through its symlinks and proves the root sits
42
+ * inside the workspace. It says nothing about the names BELOW it — and the
43
+ * writes these tools perform all go to those names: a store export copies to
44
+ * `<dest>/sessions/<id>.json`, a knowledge push appends to
45
+ * `<shared>/memories.jsonl`, a pull drops files into
46
+ * `<harness>/.crewhaus/prompts`. A symlink sitting at one of those names
47
+ * sends the write to the link's target instead, and a DANGLING one is the
48
+ * worst case: `stat` reports it absent, so it does not even look like a
49
+ * conflict, while `open(…, "w")` through it CREATES the target. Validating
50
+ * the directory and then writing through a link underneath it is exactly the
51
+ * "validate one spelling, act on another" shape this repository keeps paying
52
+ * for.
53
+ *
54
+ * Each write path therefore goes through the SAME `resolveSafe` the root went
55
+ * through — symlink-aware, dangling links followed by hand — rather than a
56
+ * second symlink walker written here. Returns the refusal, or `undefined`
57
+ * when every write path stays inside the workspace.
58
+ */
59
+ export declare function containWritePaths(toolName: string, root: SafePath, rels: ReadonlyArray<string>): Failure | undefined;
60
+ /** Resolve a directory that must already exist. */
61
+ export declare function containExistingDir(toolName: string, rel: string): Loaded<SafePath>;
62
+ /** True when `candidate` is `parent` or lives underneath it (both absolute). */
63
+ export declare function isInside(parent: string, candidate: string): boolean;
64
+ /** True when either absolute path contains the other, or they are equal. */
65
+ export declare function overlaps(a: string, b: string): boolean;
66
+ /** Epoch ms → ISO, or `undefined` when there is nothing to render. */
67
+ export declare function iso(ms: number | undefined): string | undefined;
68
+ /** A bounded slice of a list, plus the full count, for a result field. */
69
+ export declare function sample<T>(items: ReadonlyArray<T>, limit?: number): {
70
+ shown: T[];
71
+ total: number;
72
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The shared answer shapes for this package: how a caller-supplied path is
3
+ * echoed back, how a directory argument is contained, and how a read that
4
+ * COULD NOT BE PERFORMED is distinguished from one that found nothing.
5
+ *
6
+ * That last distinction is the whole point of the `Loaded` type. Four
7
+ * irreversible operations sit behind these tools; every one of them decides
8
+ * what to destroy from something it read first. A read that failed and got
9
+ * reported as an empty result is how a retention sweep deletes a store it
10
+ * could not enumerate, so an unreadable directory has its own code and its
11
+ * own reason here, and never collapses into `[]`.
12
+ */
13
+ import { statSync } from "node:fs";
14
+ import * as path from "node:path";
15
+ import { ToolPermissionError, resolveSafe } from "../paths";
16
+ /** Compact JSON — the reader is a model, and every byte is context. */
17
+ export const json = (value) => JSON.stringify(value);
18
+ /**
19
+ * How much of a caller-supplied path is echoed back in a result.
20
+ *
21
+ * The message goes into a model's context, so a caller cannot be allowed to
22
+ * spend that context with a megabyte of path, and cannot be allowed to
23
+ * smuggle control characters (a NUL, an ANSI escape, a newline that forges a
24
+ * second line of output) through a string a human or a model then reads.
25
+ */
26
+ const MAX_ECHOED_PATH_CHARS = 200;
27
+ /** A caller-supplied path, safe to put in a message: bounded and printable. */
28
+ export function renderPath(given) {
29
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: neutralising control characters is the point
30
+ const printable = given.replace(/[\u0000-\u001f\u007f]/g, "\uFFFD");
31
+ return printable.length > MAX_ECHOED_PATH_CHARS
32
+ ? `${printable.slice(0, MAX_ECHOED_PATH_CHARS)}…`
33
+ : printable;
34
+ }
35
+ export function fail(code, reason) {
36
+ return { ok: false, reason, code };
37
+ }
38
+ /** The JSON a tool returns when it will not proceed. */
39
+ export function refusal(tool, code, reason, extra = {}) {
40
+ return json({ tool, status: "refused", code, reason, ...extra });
41
+ }
42
+ /**
43
+ * Resolve a caller-supplied path inside the workspace.
44
+ *
45
+ * The NUL guard is here rather than in `paths.ts` (which is a verbatim copy
46
+ * of `@crewhaus/tool-pkg`'s, deliberately): a NUL truncates a path at the
47
+ * syscall boundary, so `a\u0000/../../etc` can resolve one way in the
48
+ * containment check and another way in the `open` that follows. Refused at
49
+ * the gate instead of surfacing later as "unreadable".
50
+ */
51
+ export function contain(toolName, rel) {
52
+ if (rel.includes("\u0000")) {
53
+ return fail("refused", `"${renderPath(rel)}" contains a NUL byte`);
54
+ }
55
+ try {
56
+ return { ok: true, value: resolveSafe(toolName, rel) };
57
+ }
58
+ catch (err) {
59
+ if (err instanceof ToolPermissionError) {
60
+ // The error's own message carries the path unrendered; rebuild it so a
61
+ // control character in the caller's input cannot reach the output.
62
+ return fail("refused", `tool "${toolName}" rejected path "${renderPath(rel)}": resolved location escapes the workspace root`);
63
+ }
64
+ throw err;
65
+ }
66
+ }
67
+ /**
68
+ * Contain every path an operation will WRITE, not just the directory it was
69
+ * handed.
70
+ *
71
+ * `contain` resolves the ROOT through its symlinks and proves the root sits
72
+ * inside the workspace. It says nothing about the names BELOW it — and the
73
+ * writes these tools perform all go to those names: a store export copies to
74
+ * `<dest>/sessions/<id>.json`, a knowledge push appends to
75
+ * `<shared>/memories.jsonl`, a pull drops files into
76
+ * `<harness>/.crewhaus/prompts`. A symlink sitting at one of those names
77
+ * sends the write to the link's target instead, and a DANGLING one is the
78
+ * worst case: `stat` reports it absent, so it does not even look like a
79
+ * conflict, while `open(…, "w")` through it CREATES the target. Validating
80
+ * the directory and then writing through a link underneath it is exactly the
81
+ * "validate one spelling, act on another" shape this repository keeps paying
82
+ * for.
83
+ *
84
+ * Each write path therefore goes through the SAME `resolveSafe` the root went
85
+ * through — symlink-aware, dangling links followed by hand — rather than a
86
+ * second symlink walker written here. Returns the refusal, or `undefined`
87
+ * when every write path stays inside the workspace.
88
+ */
89
+ export function containWritePaths(toolName, root, rels) {
90
+ const shownRoot = root.rel === "" ? "." : root.rel;
91
+ for (const rel of rels) {
92
+ const under = root.rel === "" ? rel : `${root.rel}/${rel}`;
93
+ if (!contain(toolName, under).ok) {
94
+ return fail("refused", `"${renderPath(rel)}" under "${renderPath(shownRoot)}" resolves outside the workspace root — a symlink on that path leads out, so the write would land on the link's target rather than inside "${renderPath(shownRoot)}". Remove the link and run again; there is no flag for writing through it.`);
95
+ }
96
+ }
97
+ return undefined;
98
+ }
99
+ /** Resolve a directory that must already exist. */
100
+ export function containExistingDir(toolName, rel) {
101
+ const safe = contain(toolName, rel);
102
+ if (!safe.ok)
103
+ return safe;
104
+ const shown = renderPath(rel);
105
+ let stat;
106
+ try {
107
+ stat = statSync(safe.value.real);
108
+ }
109
+ catch (err) {
110
+ const code = err.code;
111
+ // ENOENT is "there is nothing here"; EACCES/ELOOP/anything else is "this
112
+ // could not be read", which is a different answer and must not be
113
+ // reported as absence.
114
+ return code === "ENOENT"
115
+ ? fail("missing", `"${shown}" does not exist`)
116
+ : fail("unreadable", `"${shown}" could not be read (${code ?? "unknown error"})`);
117
+ }
118
+ if (!stat.isDirectory())
119
+ return fail("not-a-directory", `"${shown}" is not a directory`);
120
+ return safe;
121
+ }
122
+ /** True when `candidate` is `parent` or lives underneath it (both absolute). */
123
+ export function isInside(parent, candidate) {
124
+ const p = path.resolve(parent);
125
+ const c = path.resolve(candidate);
126
+ return c === p || c.startsWith(`${p}${path.sep}`);
127
+ }
128
+ /** True when either absolute path contains the other, or they are equal. */
129
+ export function overlaps(a, b) {
130
+ return isInside(a, b) || isInside(b, a);
131
+ }
132
+ /** Epoch ms → ISO, or `undefined` when there is nothing to render. */
133
+ export function iso(ms) {
134
+ return ms === undefined ? undefined : new Date(ms).toISOString();
135
+ }
136
+ /** A bounded slice of a list, plus the full count, for a result field. */
137
+ export function sample(items, limit = 50) {
138
+ return { shown: items.slice(0, limit), total: items.length };
139
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reading a retention selection well enough to refuse it.
3
+ *
4
+ * `@crewhaus/harness-lifecycle` decides WHAT a sweep or purge deletes: the
5
+ * age rule, the pins, the audit windows, the audit-chain exclusion. None of
6
+ * that is re-decided here. What this module adds is the question the package
7
+ * does not answer — is this selection plausibly what the operator meant? —
8
+ * and the two shapes of "no" that matter:
9
+ *
10
+ * 1. THE RULE SELECTED EVERYTHING. A window that expires every session in
11
+ * the store is either a deliberate wipe or a misconfiguration, and the
12
+ * two are indistinguishable from inside the rule. It has to be said out
13
+ * loud before the delete, not discovered after it.
14
+ * 2. A TIMESTAMP IS NOT A DATE. A session whose mtime is at or near epoch
15
+ * zero is a broken clock, a failed copy or a restored archive — not data
16
+ * from 1970. Age rules read it as infinitely old and delete it first.
17
+ * There is deliberately no flag to wave this through: the fix is to
18
+ * repair or pin the record, not to confirm a number nobody believes.
19
+ *
20
+ * The selection itself comes from running the package's OWN dry run, so the
21
+ * set being judged here is the set the real call will act on, produced by the
22
+ * same code — not a preview re-derived beside it.
23
+ */
24
+ import type { RetentionEnforcementReport } from "@crewhaus/harness-lifecycle";
25
+ /**
26
+ * Timestamps at or below this are treated as broken rather than old:
27
+ * 1971-01-01T00:00:00Z. A real harness session at or before that is not a
28
+ * thing; a zeroed or truncated mtime lands here every time.
29
+ *
30
+ * The comparison is inclusive because every sentence describing this rule —
31
+ * the tool's description, the refusal it prints — says "at or before", and a
32
+ * boundary that the prose and the code disagree about is a boundary nobody
33
+ * can reason about. Exactly `EPOCH_SANITY_MS` is a fabricated number, not
34
+ * data from the first instant of 1971.
35
+ */
36
+ export declare const EPOCH_SANITY_MS: number;
37
+ /** The shape of an inventory entry this module needs, structurally. */
38
+ export type InventorySession = {
39
+ readonly sessionId: string;
40
+ readonly record: {
41
+ readonly id: string;
42
+ readonly createdAt: number;
43
+ };
44
+ };
45
+ export type SelectedRecord = {
46
+ readonly id: string;
47
+ readonly createdAtMs: number;
48
+ readonly paths: ReadonlyArray<string>;
49
+ };
50
+ export type Selection = {
51
+ readonly records: ReadonlyArray<SelectedRecord>;
52
+ readonly count: number;
53
+ readonly oldestMs?: number;
54
+ readonly newestMs?: number;
55
+ /** Selected records whose timestamp is at or below {@link EPOCH_SANITY_MS}. */
56
+ readonly epochZero: ReadonlyArray<SelectedRecord>;
57
+ /**
58
+ * Selected ids that were not in the inventory snapshot, so their age could
59
+ * not be read. Not an empty set of timestamps — an unknown one.
60
+ */
61
+ readonly unknownTimestamp: ReadonlyArray<string>;
62
+ readonly sessionsInStore: number;
63
+ };
64
+ /**
65
+ * Join the package's deletion list to the inventory snapshot, so the caller
66
+ * can be told not just HOW MANY records would go but how old the oldest and
67
+ * newest of them are — the two numbers that make a misconfigured window
68
+ * visible before it runs.
69
+ */
70
+ export declare function summarizeSelection(report: RetentionEnforcementReport, sessions: ReadonlyArray<InventorySession>): Selection;
71
+ export type BreadthOptions = {
72
+ readonly allowDeleteAll: boolean;
73
+ readonly maxDeletions?: number;
74
+ /** The parsed `before` cutoff, epoch ms, for a purge. */
75
+ readonly cutoffMs?: number;
76
+ readonly nowMs: number;
77
+ };
78
+ export type BreadthRefusal = {
79
+ readonly code: "refused";
80
+ readonly reason: string;
81
+ };
82
+ /**
83
+ * The refusals that keep a plausible-looking rule from taking the whole
84
+ * store. Order matters: the broken-timestamp refusal comes first because it
85
+ * explains WHY a rule that looks narrow selected everything.
86
+ */
87
+ export declare function breadthRefusal(selection: Selection, opts: BreadthOptions): BreadthRefusal | undefined;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Timestamps at or below this are treated as broken rather than old:
3
+ * 1971-01-01T00:00:00Z. A real harness session at or before that is not a
4
+ * thing; a zeroed or truncated mtime lands here every time.
5
+ *
6
+ * The comparison is inclusive because every sentence describing this rule —
7
+ * the tool's description, the refusal it prints — says "at or before", and a
8
+ * boundary that the prose and the code disagree about is a boundary nobody
9
+ * can reason about. Exactly `EPOCH_SANITY_MS` is a fabricated number, not
10
+ * data from the first instant of 1971.
11
+ */
12
+ export const EPOCH_SANITY_MS = Date.UTC(1971, 0, 1);
13
+ /**
14
+ * Join the package's deletion list to the inventory snapshot, so the caller
15
+ * can be told not just HOW MANY records would go but how old the oldest and
16
+ * newest of them are — the two numbers that make a misconfigured window
17
+ * visible before it runs.
18
+ */
19
+ export function summarizeSelection(report, sessions) {
20
+ const byId = new Map();
21
+ for (const s of sessions)
22
+ byId.set(s.record.id, s);
23
+ const records = [];
24
+ const unknownTimestamp = [];
25
+ for (const deletion of report.deleted) {
26
+ const entry = byId.get(deletion.id);
27
+ if (entry === undefined) {
28
+ // The enforcement pass named a record the snapshot does not have. That
29
+ // is a real divergence (a racing writer, a store that changed shape),
30
+ // and it is reported rather than silently treated as age-zero.
31
+ unknownTimestamp.push(deletion.id);
32
+ continue;
33
+ }
34
+ records.push({ id: deletion.id, createdAtMs: entry.record.createdAt, paths: deletion.paths });
35
+ }
36
+ const times = records.map((r) => r.createdAtMs);
37
+ return {
38
+ records,
39
+ count: report.deleted.length,
40
+ ...(times.length > 0 ? { oldestMs: Math.min(...times), newestMs: Math.max(...times) } : {}),
41
+ epochZero: records.filter((r) => r.createdAtMs <= EPOCH_SANITY_MS),
42
+ unknownTimestamp,
43
+ sessionsInStore: sessions.length,
44
+ };
45
+ }
46
+ /**
47
+ * The refusals that keep a plausible-looking rule from taking the whole
48
+ * store. Order matters: the broken-timestamp refusal comes first because it
49
+ * explains WHY a rule that looks narrow selected everything.
50
+ */
51
+ export function breadthRefusal(selection, opts) {
52
+ if (selection.epochZero.length > 0) {
53
+ const shown = selection.epochZero
54
+ .slice(0, 5)
55
+ .map((r) => `${r.id} (${new Date(r.createdAtMs).toISOString()})`)
56
+ .join(", ");
57
+ return {
58
+ code: "refused",
59
+ reason: `refusing to delete on an age rule while ${selection.epochZero.length} selected record(s) carry a timestamp at or before ${new Date(EPOCH_SANITY_MS).toISOString()}: ${shown}. A timestamp that low is a broken clock or a failed copy, not data that old, and every age rule reads it as infinitely old. Repair the timestamps, or pin those sessions in .crewhaus/retention.json, and run again. There is no flag to override this.`,
60
+ };
61
+ }
62
+ if (selection.unknownTimestamp.length > 0) {
63
+ return {
64
+ code: "refused",
65
+ reason: `refusing: ${selection.unknownTimestamp.length} selected record(s) were not in the inventory snapshot, so their age could not be read (${selection.unknownTimestamp.slice(0, 5).join(", ")}). Something is writing to the store during the sweep; re-run when it is quiet.`,
66
+ };
67
+ }
68
+ if (opts.cutoffMs !== undefined &&
69
+ opts.cutoffMs > opts.nowMs &&
70
+ selection.count > 0 &&
71
+ !opts.allowDeleteAll) {
72
+ return {
73
+ code: "refused",
74
+ reason: `refusing: the purge cutoff ${new Date(opts.cutoffMs).toISOString()} is in the future, so "older than the cutoff" means every record in the store that its retention window allows. Pass a past cutoff, or set allowDeleteAll to confirm.`,
75
+ };
76
+ }
77
+ if (opts.maxDeletions !== undefined && selection.count > opts.maxDeletions) {
78
+ return {
79
+ code: "refused",
80
+ reason: `refusing: the selection is ${selection.count} record(s), over the maxDeletions limit of ${opts.maxDeletions} this call set.`,
81
+ };
82
+ }
83
+ if (!opts.allowDeleteAll &&
84
+ selection.count > 0 &&
85
+ selection.sessionsInStore > 0 &&
86
+ selection.count === selection.sessionsInStore) {
87
+ const window = selection.oldestMs !== undefined && selection.newestMs !== undefined
88
+ ? ` (oldest ${new Date(selection.oldestMs).toISOString()}, newest ${new Date(selection.newestMs).toISOString()})`
89
+ : "";
90
+ return {
91
+ code: "refused",
92
+ reason: `refusing: the age rule selects EVERY session in the store — ${selection.count} of ${selection.sessionsInStore}${window}. That is either a deliberate wipe or a misconfigured window, and this tool will not guess which. Set allowDeleteAll to confirm it is the former.`,
93
+ };
94
+ }
95
+ return undefined;
96
+ }