crewhaus 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +246 -0
  51. package/dist/feedback.js +612 -0
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11727 -740
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -64
@@ -0,0 +1,607 @@
1
+ import { existsSync } from "node:fs";
2
+ import { copyFile, mkdir, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { openAuditLog } from "@crewhaus/audit-log";
5
+ import { RETENTION_CONFIG_RELPATH, RetentionConfigError, SESSION_ID_REGEX, createDataRetentionEngine, loadRetentionConfig, } from "@crewhaus/data-retention-engine";
6
+ import { createSessionStore } from "@crewhaus/session-store";
7
+ // Config loading lives in @crewhaus/data-retention-engine (retention-config.ts)
8
+ // so the daemon janitors read the IDENTICAL policy this CLI enforces
9
+ // (ops-review F2). Re-exported here so callers/tests of this module keep one
10
+ // import surface.
11
+ export { DEFAULT_SESSION_MAX_AGE_DAYS, RETENTION_CONFIG_RELPATH, RetentionConfigError, loadRetentionConfig, } from "@crewhaus/data-retention-engine";
12
+ /**
13
+ * Item 35 — `crewhaus retention sweep|export|purge` plumbing, factored out of
14
+ * the entry file `index.ts` (which runs a top-level argv switch and so cannot
15
+ * be imported by a test without executing the CLI). Side-effect-free on
16
+ * import and directly unit-testable, mirroring `audit-verify.ts` /
17
+ * `compliance-schedule.ts`. The exported `runRetentionSweep` /
18
+ * `runRetentionExport` / `runRetentionPurge` are plain library functions so a
19
+ * future daemon janitor can call them without the CLI (no boot wiring here).
20
+ *
21
+ * What this module wires together:
22
+ *
23
+ * 1. `HarnessRecordStore` — a `@crewhaus/data-retention-engine` RecordStore
24
+ * adapter over the two real on-disk stores of a harness directory:
25
+ * - `.crewhaus/sessions` (`@crewhaus/session-store`): one record per
26
+ * `sess_<16 hex>.json`. `createdAt` is the file MTIME — the exact
27
+ * eviction key session-store's `list()` uses — and deletion goes
28
+ * through `sessionStore.delete()` so the sibling `<id>.jsonl` event
29
+ * log is unlinked too. The sweep therefore makes session TTL
30
+ * eviction happen on a schedule instead of only as a `list()` side
31
+ * effect, under the same rules.
32
+ * - `.crewhaus/audit` (`@crewhaus/audit-log`): one record per UTC
33
+ * day file (`YYYY-MM-DD.jsonl`), NEVER individual records — and in
34
+ * fact never deleted at all; see AUDIT_CHAIN_EXCLUSION_REASON.
35
+ *
36
+ * 2. `.crewhaus/retention.json` — the engine's policies/windows/pins are
37
+ * purely programmatic (in-memory maps on the engine instance), so the
38
+ * config file is read via `@crewhaus/data-retention-engine`'s exported
39
+ * `loadRetentionConfig` (schema + safety stance live there, in
40
+ * retention-config.ts; the daemon janitors read the SAME file with the
41
+ * SAME parser). `sessions.maxAgeDays` becomes an engine `retain()`
42
+ * policy, `pins` become adapter deletion refusals, and `auditWindows`
43
+ * become `addAuditWindow()` registrations (already-expired entries are
44
+ * ignored).
45
+ *
46
+ * 3. Evidence: every REAL (non-dry-run) sweep/export/purge appends one
47
+ * `retention_enforcement` record to `.crewhaus/audit` — the same
48
+ * hash-chained store the `run` justification gate writes — so retention
49
+ * enforcement is itself tamper-evidenced. Dry runs append nothing. A
50
+ * no-op run against a directory with no existing audit store also
51
+ * appends nothing: fabricating `.crewhaus/audit` just to log that
52
+ * nothing happened would litter arbitrary directories (ops-review F8b).
53
+ *
54
+ * Tenancy: `AuditRecord` carries no tenantId and sessions are per-directory,
55
+ * so everything here operates on the single local tenant (`LOCAL_TENANT`).
56
+ * A `--tenant` surface is deferred until the stores are tenant-scoped.
57
+ *
58
+ * Safety stance: when in doubt (unrecognised filename, unparseable day label,
59
+ * orphaned event log), SKIP the entry and report it — never delete.
60
+ */
61
+ const SESSIONS_RELPATH = ".crewhaus/sessions";
62
+ const AUDIT_RELPATH = ".crewhaus/audit";
63
+ /** Single-tenant placeholder: the on-disk stores carry no tenant ids. */
64
+ export const LOCAL_TENANT = "local";
65
+ const MS_PER_DAY = 86_400_000;
66
+ /**
67
+ * `defaultRetentionDays` for kinds WITHOUT an explicit policy (i.e. anything
68
+ * but "session"): effectively never — an unconfigured kind must not be
69
+ * deletable by accident. ~100 years.
70
+ */
71
+ const NEVER_EXPIRE_DAYS = 36_500;
72
+ const AUDIT_DAY_FILE_REGEX = /^(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
73
+ const CHAIN_TAIL_FILENAME = "_chain-tail.json";
74
+ /**
75
+ * Why NO audit data — not individual records, not whole day files — is ever
76
+ * deleted by retention. Kept as an exported constant so the report, the
77
+ * adapter refusal, and the tests all cite the same rationale.
78
+ */
79
+ export const AUDIT_CHAIN_EXCLUSION_REASON = "audit chain integrity — @crewhaus/audit-log verify() walks every day file as ONE " +
80
+ "hash chain (prevHash from GENESIS in the oldest file, seq gapless from 0 across " +
81
+ "files), so deleting any record OR any whole day file — even the oldest — breaks " +
82
+ "verification of every surviving record after the gap";
83
+ /** Thrown by `parseRetentionDate` on an unparseable `--since`/`--before`. */
84
+ export class InvalidRetentionDateError extends Error {
85
+ name = "InvalidRetentionDateError";
86
+ constructor(flag, value) {
87
+ super(`invalid ${flag} "${value}" — expected an ISO date (YYYY-MM-DD) or datetime (e.g. 2026-07-01T00:00:00Z)`);
88
+ }
89
+ }
90
+ /** Parse a `--since`/`--before` flag value to epoch ms (UTC for bare dates). */
91
+ export function parseRetentionDate(flag, value) {
92
+ const ms = Date.parse(value);
93
+ if (Number.isNaN(ms))
94
+ throw new InvalidRetentionDateError(flag, value);
95
+ return ms;
96
+ }
97
+ /**
98
+ * RecordStore adapter over `<rootDir>/.crewhaus/sessions` +
99
+ * `<rootDir>/.crewhaus/audit`. The inventory is a snapshot taken by
100
+ * `openHarnessRecordStore` (a sweep operates on the state it observed at
101
+ * start). Deletions are tracked for reporting; in `dryRun` mode `delete()`
102
+ * makes the same decision (and records it) without touching the disk, so the
103
+ * engine's counters mirror a real run exactly.
104
+ */
105
+ export class HarnessRecordStore {
106
+ sessions;
107
+ auditDays;
108
+ skipped;
109
+ deletions = [];
110
+ refusals = [];
111
+ byId;
112
+ pins;
113
+ dryRun;
114
+ sessionsDir;
115
+ constructor(opts) {
116
+ this.sessionsDir = opts.sessionsDir;
117
+ this.sessions = opts.sessions;
118
+ this.auditDays = opts.auditDays;
119
+ this.skipped = opts.skipped;
120
+ this.pins = new Set(opts.pins);
121
+ this.dryRun = opts.dryRun;
122
+ const byId = new Map();
123
+ for (const s of opts.sessions)
124
+ byId.set(s.record.id, s);
125
+ for (const a of opts.auditDays)
126
+ byId.set(a.record.id, a);
127
+ this.byId = byId;
128
+ }
129
+ async *listAll() {
130
+ for (const s of this.sessions)
131
+ yield s.record;
132
+ for (const a of this.auditDays)
133
+ yield a.record;
134
+ }
135
+ async *listByTenant(tenantId) {
136
+ for await (const r of this.listAll()) {
137
+ if (r.tenantId === tenantId)
138
+ yield r;
139
+ }
140
+ }
141
+ async delete(id) {
142
+ // GRANULARITY CONSTRAINT (enforced here, the single deletion choke
143
+ // point): audit data is NEVER deleted by retention — not individual
144
+ // records, not whole `YYYY-MM-DD.jsonl` day files. `verify()` in
145
+ // @crewhaus/audit-log walks every day file in sorted order as ONE hash
146
+ // chain: `prevHash` starts at GENESIS in the OLDEST file and `seq` must
147
+ // be gapless from 0 across files, so removing even the oldest whole day
148
+ // file leaves the next surviving record with prevHash != GENESIS and
149
+ // seq != 0 — verify() then fails on the FIRST surviving line and the
150
+ // entire remaining log is unverifiable. Until audit-log grows a
151
+ // chain-aware archival/re-anchor operation, retention treats
152
+ // `.crewhaus/audit` as read-only: enumerate + export, never delete.
153
+ if (id.startsWith("audit:")) {
154
+ this.refusals.push({ id, reason: AUDIT_CHAIN_EXCLUSION_REASON });
155
+ return false;
156
+ }
157
+ const entry = this.byId.get(id);
158
+ if (entry === undefined || !("sessionId" in entry))
159
+ return false;
160
+ if (this.pins.has(entry.sessionId)) {
161
+ this.refusals.push({ id, reason: `pinned in ${RETENTION_CONFIG_RELPATH}` });
162
+ return false;
163
+ }
164
+ const paths = [
165
+ entry.jsonPath,
166
+ ...(entry.eventLogPath !== undefined ? [entry.eventLogPath] : []),
167
+ ];
168
+ this.deletions.push({ id, paths });
169
+ if (!this.dryRun) {
170
+ // Reuse session-store's own deletion (id validation + unlink of both
171
+ // the session file AND the sibling event log) instead of re-implementing
172
+ // its eviction rules.
173
+ await createSessionStore({ rootDir: this.sessionsDir }).delete(entry.sessionId);
174
+ }
175
+ return true;
176
+ }
177
+ }
178
+ /** `YYYY-MM-DD` → epoch ms of the LAST instant of that UTC day, so a day
179
+ * file only counts as expired once its newest possible record is. Returns
180
+ * undefined for a label that doesn't round-trip (e.g. `2026-13-40`). */
181
+ function endOfUtcDayMs(y, m, d) {
182
+ const startMs = Date.UTC(Number(y), Number(m) - 1, Number(d));
183
+ const roundTrip = new Date(startMs).toISOString().slice(0, 10);
184
+ if (roundTrip !== `${y}-${m}-${d}`)
185
+ return undefined;
186
+ return startMs + MS_PER_DAY - 1;
187
+ }
188
+ /**
189
+ * Snapshot the two stores under `rootDir` into a `HarnessRecordStore`.
190
+ * Anything unrecognised is SKIPPED with a reason, never deleted:
191
+ * `.tmp` leftovers, non-`sess_` filenames, orphaned event logs (a
192
+ * `<id>.jsonl` without its `<id>.json`), and non-day files in the audit dir.
193
+ */
194
+ export async function openHarnessRecordStore(opts) {
195
+ const sessionsDir = join(opts.rootDir, SESSIONS_RELPATH);
196
+ const auditDir = join(opts.rootDir, AUDIT_RELPATH);
197
+ const skipped = [];
198
+ const sessions = [];
199
+ const auditDays = [];
200
+ let sessionEntries = [];
201
+ try {
202
+ sessionEntries = await readdir(sessionsDir);
203
+ }
204
+ catch (err) {
205
+ if (err.code !== "ENOENT")
206
+ throw err;
207
+ }
208
+ const sessionEntrySet = new Set(sessionEntries);
209
+ for (const entry of sessionEntries.sort()) {
210
+ const full = join(sessionsDir, entry);
211
+ if (entry.endsWith(".json")) {
212
+ const id = entry.slice(0, -".json".length);
213
+ if (!SESSION_ID_REGEX.test(id)) {
214
+ skipped.push({ path: full, reason: "not a session file (expected sess_<16 hex>.json)" });
215
+ continue;
216
+ }
217
+ let mtimeMs;
218
+ try {
219
+ mtimeMs = (await stat(full)).mtimeMs;
220
+ }
221
+ catch (err) {
222
+ if (err.code === "ENOENT")
223
+ continue; // raced away
224
+ throw err;
225
+ }
226
+ const eventLog = `${id}.jsonl`;
227
+ sessions.push({
228
+ sessionId: id,
229
+ jsonPath: full,
230
+ ...(sessionEntrySet.has(eventLog) ? { eventLogPath: join(sessionsDir, eventLog) } : {}),
231
+ record: {
232
+ id: `session:${id}`,
233
+ tenantId: LOCAL_TENANT,
234
+ kind: "session",
235
+ // The file MTIME, deliberately — session-store's list() eviction is
236
+ // keyed on mtime (not the in-file updatedAt), so the sweep expires
237
+ // exactly what list() would have evicted. The JSON content is not
238
+ // consulted: it cannot change the decision, so a malformed session
239
+ // file cannot cause a wrong deletion.
240
+ createdAt: mtimeMs,
241
+ payload: { file: full },
242
+ },
243
+ });
244
+ }
245
+ else if (entry.endsWith(".jsonl")) {
246
+ const id = entry.slice(0, -".jsonl".length);
247
+ if (SESSION_ID_REGEX.test(id) && sessionEntrySet.has(`${id}.json`)) {
248
+ continue; // companion event log — lives and dies with its session file
249
+ }
250
+ skipped.push({
251
+ path: full,
252
+ reason: SESSION_ID_REGEX.test(id)
253
+ ? "orphaned session event log (no matching .json) — left in place"
254
+ : "not a session artifact",
255
+ });
256
+ }
257
+ else {
258
+ skipped.push({ path: full, reason: "not a session artifact (e.g. a .tmp leftover)" });
259
+ }
260
+ }
261
+ let auditEntries = [];
262
+ try {
263
+ auditEntries = await readdir(auditDir);
264
+ }
265
+ catch (err) {
266
+ if (err.code !== "ENOENT")
267
+ throw err;
268
+ }
269
+ for (const entry of auditEntries.sort()) {
270
+ const full = join(auditDir, entry);
271
+ if (entry === CHAIN_TAIL_FILENAME)
272
+ continue; // audit-log's internal tail anchor
273
+ const m = AUDIT_DAY_FILE_REGEX.exec(entry);
274
+ if (m === null) {
275
+ skipped.push({ path: full, reason: "not an audit day file (expected YYYY-MM-DD.jsonl)" });
276
+ continue;
277
+ }
278
+ const day = entry.slice(0, -".jsonl".length);
279
+ const createdAt = endOfUtcDayMs(m[1], m[2], m[3]);
280
+ if (createdAt === undefined) {
281
+ skipped.push({ path: full, reason: `unparseable day label "${day}"` });
282
+ continue;
283
+ }
284
+ auditDays.push({
285
+ day,
286
+ path: full,
287
+ record: {
288
+ id: `audit:${day}`,
289
+ tenantId: LOCAL_TENANT,
290
+ kind: "audit",
291
+ createdAt,
292
+ payload: { file: full, day },
293
+ },
294
+ });
295
+ }
296
+ return new HarnessRecordStore({
297
+ sessionsDir,
298
+ sessions,
299
+ auditDays,
300
+ skipped,
301
+ pins: opts.pins ?? [],
302
+ dryRun: opts.dryRun === true,
303
+ });
304
+ }
305
+ async function runRetentionEnforcement(opts) {
306
+ const rootDir = resolve(opts.rootDir);
307
+ const config = await loadRetentionConfig(rootDir, opts.now);
308
+ const store = await openHarnessRecordStore({
309
+ rootDir,
310
+ pins: config.pins,
311
+ dryRun: opts.dryRun,
312
+ });
313
+ const engine = createDataRetentionEngine({
314
+ recordStore: store,
315
+ now: opts.now,
316
+ // Safe by default: any kind WITHOUT an explicit policy below effectively
317
+ // never expires — only "session" gets a real TTL. ("audit" additionally
318
+ // can never be deleted at all; the adapter's delete() refuses it.)
319
+ defaultRetentionDays: NEVER_EXPIRE_DAYS,
320
+ });
321
+ engine.retain(LOCAL_TENANT, "session", config.sessionMaxAgeDays);
322
+ for (const w of config.auditWindows)
323
+ engine.addAuditWindow(w);
324
+ // purge() rather than sweep(): both apply the identical window → retention
325
+ // → delete rules, but purge() returns the per-record deferral arrays the
326
+ // --dry-run report needs (sweep() only counts). With a single local tenant
327
+ // they cover the same records.
328
+ const result = await engine.purge(LOCAL_TENANT, opts.before !== undefined ? { before: opts.before } : {});
329
+ const auditIds = new Set(store.auditDays.map((a) => a.record.id));
330
+ const keptWithinRetention = result.retentionDeferred.filter((id) => !auditIds.has(id));
331
+ // Audit day files land in retentionDeferred (NEVER_EXPIRE default) or, when
332
+ // a window is active, in deferred — either way the load-bearing reason is
333
+ // the chain exclusion, so report them all under it.
334
+ const keptAuditChain = store.auditDays.map((a) => a.record.id);
335
+ const keptAuditWindow = result.deferred.filter((id) => !auditIds.has(id));
336
+ const keptOutsideCutoff = opts.before === undefined
337
+ ? []
338
+ : store.sessions
339
+ .filter((s) => s.record.createdAt >= opts.before)
340
+ .map((s) => s.record.id);
341
+ const keptPinned = store.refusals.filter((r) => r.id.startsWith("session:"));
342
+ let evidence;
343
+ // Evidence gating (ops-review F8b): dry runs never append. A REAL run
344
+ // appends when it deleted something (enforcement happened — evidence it,
345
+ // creating the audit store if this harness lacked one) or when an audit
346
+ // store already exists to receive the record. A no-op run in a directory
347
+ // with no audit store appends nothing — `crewhaus retention sweep` in an
348
+ // arbitrary directory must not fabricate `.crewhaus/audit`.
349
+ const auditStoreExists = existsSync(join(rootDir, AUDIT_RELPATH));
350
+ if (!opts.dryRun && (store.deletions.length > 0 || auditStoreExists)) {
351
+ evidence = await appendEvidence(rootDir, opts.now, {
352
+ action: opts.action,
353
+ dryRun: false,
354
+ ...(opts.before !== undefined ? { before: new Date(opts.before).toISOString() } : {}),
355
+ policy: {
356
+ sessionMaxAgeDays: config.sessionMaxAgeDays,
357
+ pins: config.pins,
358
+ auditWindows: config.auditWindows.map((w) => `${w.frameworkId}/${w.controlId}`),
359
+ },
360
+ deletedSessionIds: store.deletions.map((d) => d.id),
361
+ counts: {
362
+ deleted: store.deletions.length,
363
+ keptPinned: keptPinned.length,
364
+ keptAuditChain: keptAuditChain.length,
365
+ keptWithinRetention: keptWithinRetention.length,
366
+ keptAuditWindow: keptAuditWindow.length,
367
+ keptOutsideCutoff: keptOutsideCutoff.length,
368
+ skipped: store.skipped.length,
369
+ },
370
+ });
371
+ }
372
+ return {
373
+ action: opts.action,
374
+ rootDir,
375
+ dryRun: opts.dryRun,
376
+ config,
377
+ ...(opts.before !== undefined ? { before: opts.before } : {}),
378
+ deleted: [...store.deletions],
379
+ keptPinned,
380
+ keptAuditChain,
381
+ keptWithinRetention,
382
+ keptAuditWindow,
383
+ keptOutsideCutoff,
384
+ activeAuditWindows: result.auditWindowDeferred,
385
+ skipped: store.skipped,
386
+ ...(evidence !== undefined ? { evidence } : {}),
387
+ };
388
+ }
389
+ /** Append the `retention_enforcement` evidence record to `.crewhaus/audit`
390
+ * — the same append path (openAuditLog on the harness audit root) the run
391
+ * justification sink uses, so enforcement lands in the verifiable chain. */
392
+ async function appendEvidence(rootDir, now, payload) {
393
+ const log = await openAuditLog({ rootDir: join(rootDir, AUDIT_RELPATH), now });
394
+ const record = await log.append({ kind: "retention_enforcement", payload });
395
+ return { seq: record.seq, hash: record.hash };
396
+ }
397
+ /**
398
+ * `crewhaus retention sweep` — the scheduled GDPR/TTL enforcement pass.
399
+ * Deletes expired sessions (mtime older than `sessions.maxAgeDays`), honors
400
+ * pins + audit windows, never touches audit data, and appends a
401
+ * `retention_enforcement` evidence record on real runs. Exported as a plain
402
+ * library function so a daemon janitor can call it without the CLI.
403
+ */
404
+ export async function runRetentionSweep(opts) {
405
+ return runRetentionEnforcement({
406
+ rootDir: opts.rootDir,
407
+ action: "sweep",
408
+ dryRun: opts.dryRun === true,
409
+ now: opts.now ?? (() => Date.now()),
410
+ });
411
+ }
412
+ /**
413
+ * `crewhaus retention purge` — the explicit right-to-delete verb. Identical
414
+ * rules to the sweep (pins, audit windows, retention TTLs, audit exclusion)
415
+ * restricted to records older than `--before` when given. Not a bypass:
416
+ * a record still inside its retention window is deferred, not deleted.
417
+ */
418
+ export async function runRetentionPurge(opts) {
419
+ return runRetentionEnforcement({
420
+ rootDir: opts.rootDir,
421
+ action: "purge",
422
+ dryRun: opts.dryRun === true,
423
+ ...(opts.before !== undefined ? { before: opts.before } : {}),
424
+ now: opts.now ?? (() => Date.now()),
425
+ });
426
+ }
427
+ /** True when `a` equals `b`, or either path contains the other. */
428
+ function pathsOverlap(a, b) {
429
+ return a === b || a.startsWith(`${b}${sep}`) || b.startsWith(`${a}${sep}`);
430
+ }
431
+ /**
432
+ * `crewhaus retention export` — the right-to-export verb. Copies matching
433
+ * records OUT of the harness stores (originals untouched) as raw files, so
434
+ * an exported audit set keeps its verbatim hash-chain lines:
435
+ *
436
+ * <outDir>/sessions/<id>.json (+ <id>.jsonl event log when present)
437
+ * <outDir>/audit/<day>.jsonl (+ _chain-tail.json on a complete export)
438
+ * <outDir>/manifest.json
439
+ *
440
+ * Non-destructive, so pins and audit windows do not gate it; it IS
441
+ * audit-logged (a `retention_enforcement` record with action "export").
442
+ */
443
+ export async function runRetentionExport(opts) {
444
+ const rootDir = resolve(opts.rootDir);
445
+ const outDir = resolve(opts.outDir);
446
+ const dryRun = opts.dryRun === true;
447
+ const now = opts.now ?? (() => Date.now());
448
+ const sessionsDir = join(rootDir, SESSIONS_RELPATH);
449
+ const auditDir = join(rootDir, AUDIT_RELPATH);
450
+ const sessionsOut = join(outDir, "sessions");
451
+ const auditOut = join(outDir, "audit");
452
+ // Containment (ops-review F5): refuse any outDir whose RESOLVED output
453
+ // paths (outDir itself, <outDir>/sessions, <outDir>/audit) equal, sit
454
+ // inside, or CONTAIN a live store. The classic foot-gun is
455
+ // `retention export <root>/.crewhaus`, whose sessions/audit outputs are
456
+ // exactly the live stores — a self-copy onto the data being exported.
457
+ for (const outPath of [outDir, sessionsOut, auditOut]) {
458
+ for (const storeDir of [sessionsDir, auditDir]) {
459
+ if (pathsOverlap(outPath, storeDir)) {
460
+ throw new RetentionConfigError(`export output path ${outPath} overlaps the live store ${storeDir} — refusing to write export output onto the store being exported`);
461
+ }
462
+ }
463
+ }
464
+ const store = await openHarnessRecordStore({ rootDir, dryRun: true });
465
+ const matchesSince = (createdAt) => opts.since === undefined || createdAt >= opts.since;
466
+ const exportedSessions = [];
467
+ for (const s of store.sessions) {
468
+ if (!matchesSince(s.record.createdAt))
469
+ continue;
470
+ if (!dryRun) {
471
+ await mkdir(sessionsOut, { recursive: true });
472
+ await copyFile(s.jsonPath, join(sessionsOut, `${s.sessionId}.json`));
473
+ if (s.eventLogPath !== undefined) {
474
+ await copyFile(s.eventLogPath, join(sessionsOut, `${s.sessionId}.jsonl`));
475
+ }
476
+ }
477
+ exportedSessions.push(s.sessionId);
478
+ }
479
+ const exportedDays = [];
480
+ for (const a of store.auditDays) {
481
+ if (!matchesSince(a.record.createdAt))
482
+ continue;
483
+ if (!dryRun) {
484
+ await mkdir(auditOut, { recursive: true });
485
+ await copyFile(a.path, join(auditOut, `${a.day}.jsonl`));
486
+ }
487
+ exportedDays.push(a.day);
488
+ }
489
+ const complete = exportedDays.length === store.auditDays.length && exportedDays.length > 0;
490
+ const chainTailPath = join(auditDir, CHAIN_TAIL_FILENAME);
491
+ const chainTailCopied = complete && existsSync(chainTailPath);
492
+ if (chainTailCopied && !dryRun) {
493
+ await copyFile(chainTailPath, join(auditOut, CHAIN_TAIL_FILENAME));
494
+ }
495
+ // Dry run (ops-review F1): report what WOULD be exported and stop — no
496
+ // outDir, no manifest, no audit evidence, nothing on disk at all.
497
+ if (dryRun) {
498
+ return {
499
+ rootDir,
500
+ outDir,
501
+ dryRun: true,
502
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
503
+ sessions: exportedSessions,
504
+ auditDays: exportedDays,
505
+ chainTailCopied,
506
+ skipped: store.skipped,
507
+ };
508
+ }
509
+ // Evidence AFTER the copies: the record describes the completed export and
510
+ // must not appear inside the exported snapshot itself. Gated like the
511
+ // enforcement verbs (ops-review F8b): appended when something was actually
512
+ // exported, or when an audit store already exists to receive it — a no-op
513
+ // export must not fabricate `.crewhaus/audit`.
514
+ let evidence;
515
+ if (exportedSessions.length + exportedDays.length > 0 || existsSync(auditDir)) {
516
+ evidence = await appendEvidence(rootDir, now, {
517
+ action: "export",
518
+ dryRun: false,
519
+ outDir,
520
+ ...(opts.since !== undefined ? { since: new Date(opts.since).toISOString() } : {}),
521
+ counts: {
522
+ sessions: exportedSessions.length,
523
+ auditDays: exportedDays.length,
524
+ skipped: store.skipped.length,
525
+ },
526
+ chainTailCopied,
527
+ });
528
+ }
529
+ await mkdir(outDir, { recursive: true });
530
+ await writeFile(join(outDir, "manifest.json"), `${JSON.stringify({
531
+ exportedAt: new Date(now()).toISOString(),
532
+ rootDir,
533
+ ...(opts.since !== undefined ? { since: new Date(opts.since).toISOString() } : {}),
534
+ sessions: exportedSessions,
535
+ auditDays: exportedDays,
536
+ chainTailCopied,
537
+ skipped: store.skipped,
538
+ ...(evidence !== undefined ? { evidence } : {}),
539
+ }, null, 2)}\n`);
540
+ return {
541
+ rootDir,
542
+ outDir,
543
+ dryRun: false,
544
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
545
+ sessions: exportedSessions,
546
+ auditDays: exportedDays,
547
+ chainTailCopied,
548
+ skipped: store.skipped,
549
+ ...(evidence !== undefined ? { evidence } : {}),
550
+ };
551
+ }
552
+ /** Render a sweep/purge report as the CLI's indented summary lines. */
553
+ export function formatEnforcementReport(report) {
554
+ const lines = [];
555
+ const verb = report.dryRun ? "would delete" : "deleted";
556
+ lines.push(`policy: sessions maxAgeDays=${report.config.sessionMaxAgeDays}${report.config.fromFile ? ` (${RETENTION_CONFIG_RELPATH})` : " (default)"}, pins=${report.config.pins.length}; audit: excluded from deletion (chain integrity)`);
557
+ for (const w of report.activeAuditWindows) {
558
+ lines.push(`~ audit window active — ${w.frameworkId}/${w.controlId} defers ALL deletion until ${new Date(w.expiresAt).toISOString()}`);
559
+ }
560
+ for (const d of report.deleted) {
561
+ lines.push(`${report.dryRun ? "→" : "✓"} ${verb} ${d.id} (${d.paths.join(", ")})`);
562
+ }
563
+ for (const k of report.keptPinned)
564
+ lines.push(`• kept ${k.id} — ${k.reason}`);
565
+ for (const id of report.keptWithinRetention) {
566
+ lines.push(`• kept ${id} — within retention (< ${report.config.sessionMaxAgeDays}d)`);
567
+ }
568
+ for (const id of report.keptOutsideCutoff) {
569
+ lines.push(`• kept ${id} — newer than --before ${new Date(report.before).toISOString()}`);
570
+ }
571
+ for (const id of report.keptAuditWindow)
572
+ lines.push(`• kept ${id} — audit window active`);
573
+ if (report.keptAuditChain.length > 0) {
574
+ lines.push(`• kept ${report.keptAuditChain.length} audit day file(s) (${report.keptAuditChain[0]}${report.keptAuditChain.length > 1 ? " …" : ""}) — ${AUDIT_CHAIN_EXCLUSION_REASON}`);
575
+ }
576
+ for (const s of report.skipped)
577
+ lines.push(`~ skipped ${s.path} — ${s.reason}`);
578
+ lines.push(`summary: ${report.deleted.length} ${verb}, ${report.keptPinned.length +
579
+ report.keptWithinRetention.length +
580
+ report.keptAuditWindow.length +
581
+ report.keptOutsideCutoff.length +
582
+ report.keptAuditChain.length} kept, ${report.skipped.length} skipped${report.evidence !== undefined
583
+ ? ` — evidence appended to ${AUDIT_RELPATH} (seq ${report.evidence.seq})`
584
+ : report.dryRun
585
+ ? " — dry run: nothing deleted, no evidence appended"
586
+ : ` — no-op: nothing deleted and no ${AUDIT_RELPATH} store present, no evidence appended`}`);
587
+ return lines;
588
+ }
589
+ /** Render an export report as the CLI's indented summary lines. */
590
+ export function formatExportReport(report) {
591
+ const lines = [];
592
+ const mark = report.dryRun ? "→" : "✓";
593
+ const verb = report.dryRun ? "would export" : "exported";
594
+ lines.push(`${mark} ${verb} ${report.sessions.length} session(s) → ${join(report.outDir, "sessions")}`);
595
+ lines.push(`${mark} ${verb} ${report.auditDays.length} audit day file(s) → ${join(report.outDir, "audit")}`);
596
+ lines.push(report.chainTailCopied
597
+ ? `${mark} complete audit export — _chain-tail.json ${report.dryRun ? "would be included" : "included"}, re-verify with: crewhaus audit verify --dir ${join(report.outDir, "audit")}`
598
+ : "~ partial audit export — _chain-tail.json omitted (a mid-stream chain cannot re-verify standalone)");
599
+ for (const s of report.skipped)
600
+ lines.push(`~ skipped ${s.path} — ${s.reason}`);
601
+ lines.push(report.dryRun
602
+ ? "summary: dry run — nothing written, no evidence appended"
603
+ : `summary: manifest.json written${report.evidence !== undefined
604
+ ? ` — evidence appended to ${AUDIT_RELPATH} (seq ${report.evidence.seq})`
605
+ : ` — no-op: nothing exported and no ${AUDIT_RELPATH} store present, no evidence appended`}`);
606
+ return lines;
607
+ }