@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.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # @crewhaus/tool-lifecycle
2
+
3
+ The back half of a harness's life, as deterministic tools: retirement, store
4
+ migration, retention enforcement and knowledge sync.
5
+
6
+ These four are the operations that are irreversible when they are wrong. A
7
+ retirement moves a harness's durable state out and removes the live copy. A
8
+ retention sweep deletes sessions on an age rule. A store migration writes one
9
+ store's contents into another root. A knowledge push copies a harness's
10
+ memories into a store other harnesses read. None of them can be undone by
11
+ running them again with a better flag — so all four default to a dry run, and
12
+ all four refuse a selection wider than the caller plainly meant.
13
+
14
+ ```yaml
15
+ tools:
16
+ - harnessRetire
17
+ - storeMigrate
18
+ - retentionEnforce
19
+ - knowledgeSync
20
+ ```
21
+
22
+ | Tool | What it does |
23
+ |---|---|
24
+ | `HarnessRetire` | Fingerprint a harness's `.crewhaus`, archive it, remove the live copy, and verify the archive against the fingerprint |
25
+ | `StoreMigrate` | Copy a harness's sessions and audit files into another store root, verify every byte, and write a receipt |
26
+ | `RetentionEnforce` | Run the TTL sweep or an explicit purge under `.crewhaus/retention.json`, and evidence it in the audit chain |
27
+ | `KnowledgeSync` | Push a harness's memories and fragments to a shared store, or pull the store's into the harness |
28
+
29
+ ## The real library, not a second implementation
30
+
31
+ Every rule these tools enforce belongs to `@crewhaus/harness-lifecycle`, which
32
+ is where `apps/cli` kept it before a tool could reach it:
33
+
34
+ | Rule | Where it lives |
35
+ |---|---|
36
+ | Retirement plan, step order, active-pin refusal, abort-before-destruction, `retirement.json` | `buildRetirementPlan` / `runRetirement` |
37
+ | Age rule, pins, audit windows, the audit-chain deletion exclusion, the evidence record | `runRetentionSweep` / `runRetentionPurge` |
38
+ | Store enumeration and the verbatim copy | `openHarnessRecordStore` / `runRetentionExport` |
39
+ | Shared-record validation (including the re-hash that defeats dedupe poisoning) | `validateSharedMemory` / `readSharedFragments` |
40
+ | Credential masking and the drop-if-still-secret verdict | `buildKnowledgeRedactor` |
41
+ | Push/pull planning and application | `planPush` / `applyPush` / `planPull` / `applyPull` |
42
+
43
+ This package contributes the schema, the containment, the refusals and the
44
+ result shape. It contains no second copy of any of those rules — the whole
45
+ point of lifting them out of the CLI was that a second copy drifts.
46
+
47
+ ## Four properties
48
+
49
+ **Dry run is the default, and it is the same code.** `dryRun` defaults to
50
+ `true` everywhere here; acting takes an explicit `dryRun: false`. The preview
51
+ is produced by calling the same library function with its own dry-run flag, so
52
+ the selection being previewed is the selection the real call acts on. Where the
53
+ two can still disagree — the library re-enumerates, and a store can change
54
+ between the calls — the difference is computed and reported as
55
+ `divergedFromPreview` instead of being hidden. `RetentionEnforce` reads the
56
+ clock once and hands the same instant to both passes, so at least the age rule
57
+ cannot move underneath them.
58
+
59
+ **Could not determine is not no.** A directory that could not be listed is
60
+ never reported as empty. An unreadable registry manifest is never reported as
61
+ "no pins" — that is the one that retires a harness an environment is still
62
+ pointed at. An archive that could not be located is never reported as verified.
63
+ A step this build did not perform reports that it did not perform it, rather
64
+ than returning success.
65
+
66
+ **Containment.** Every caller-supplied path goes through `resolveSafe` (copied
67
+ verbatim from `@crewhaus/tool-pkg`), which refuses anything resolving outside
68
+ `process.cwd()`, including through a symlink inside the workspace. A path
69
+ carrying a NUL is refused before any syscall sees it, because a NUL truncates a
70
+ path at the syscall boundary and the check would then be looking at a different
71
+ path from the `open`.
72
+
73
+ Containing the *directory* is not enough, so every path these tools actually
74
+ write to — or delete through — goes through the same check: `<dest>/sessions/<id>.json`,
75
+ `<shared>/memories.jsonl`, `<harness>/.crewhaus/prompts/…`, `.crewhaus/sessions`
76
+ itself. A symlink at one of those names sends the write, or the unlink, to the
77
+ link's target, and a *dangling* one does not even look like a conflict — `stat`
78
+ calls it absent while `open(…, "w")` through it creates the target. There is no
79
+ flag past this: `overwrite` replaces files, it is not consent to leave the
80
+ workspace.
81
+
82
+ Flags are parsed first and acted on as the parsed value:
83
+ a `before` or `since` becomes epoch ms via the library's own
84
+ `parseRetentionDate`, and the result echoes the timestamp that was actually
85
+ used, never the caller's string as though it were the cutoff.
86
+
87
+ **Verification, not optimism.** `HarnessRetire` fingerprints every file under
88
+ `.crewhaus` with sha256 before the move and re-hashes the archived tree
89
+ afterwards. `StoreMigrate` hashes each source file before the copy (after,
90
+ the library has already appended its own evidence record to the source) and
91
+ re-hashes the destination. Both report per-file mismatches, and both report an
92
+ entry they could not hash as *unverifiable* rather than counting it as
93
+ verified.
94
+
95
+ ## What each tool refuses
96
+
97
+ **`HarnessRetire`**
98
+
99
+ - an environment still pinned to a registered version (the library's own rule);
100
+ - a registry manifest that cannot be read or parsed — refused as `unreadable`,
101
+ never read as "unpinned";
102
+ - a registry entry with versions this build cannot delete: the step fails, the
103
+ library aborts before the destructive move, and the live state is untouched.
104
+ There is deliberately no `force`: forcing past the pin refusal would leave
105
+ environments pointing at state that no longer exists, and this build cannot
106
+ tombstone them;
107
+ - an archive directory that is not empty, because `runRetirement` *replaces* an
108
+ existing `crewhaus-state` inside it — retiring twice into one archive would
109
+ destroy the first harness's copy (pass `overwriteArchive` if that is really
110
+ the intent);
111
+ - an archive directory overlapping the state directory it archives;
112
+ - an archive directory where the retirement log or the fingerprint manifest
113
+ would be written through a symlink leading out of the workspace;
114
+ - a `.crewhaus` that is a *symlink*: `renameSync` moves the link, so the data
115
+ would stay exactly where it is while the harness lost its pointer to it, and
116
+ a verification that followed the link into the archive would pass;
117
+ - a `.crewhaus` that exists but is **not a directory** — an entry that could
118
+ not be identified is not an absent one, and the archive step would move it
119
+ anyway;
120
+ - a state tree over `maxStateFiles` / `maxStateBytes` (50,000 files / 512 MiB by
121
+ default), because the fingerprint is the slow part and an unbounded one is
122
+ not a budget;
123
+ - a real run without `acceptUnverified`, see below.
124
+
125
+ **`StoreMigrate`**
126
+
127
+ - a destination overlapping a live store (the library's own refusal);
128
+ - a destination holding a `store-migration.json` from a *different* source —
129
+ two harnesses' stores merged into one root cannot be separated afterwards,
130
+ and their audit chains cannot both verify;
131
+ - a destination whose previous receipt cannot be read, unless `overwrite`;
132
+ - files at the destination that would be written over, unless `overwrite` —
133
+ counted by NAME, and counting everything the run *writes* rather than only
134
+ what it copies: the export path drops its own `manifest.json` beside the
135
+ copies and this tool adds `store-migration.json`, and both are in
136
+ `wouldWrite`;
137
+ - a destination path that is a symlink leading out of the workspace, with no
138
+ override;
139
+ - a `targetVersion` different from the source's, and any `targetVersion` at all
140
+ against a store with no version stamp. See below.
141
+
142
+ **`RetentionEnforce`**
143
+
144
+ - a selection covering *every* session in the store, unless `allowDeleteAll`
145
+ says that is the intent;
146
+ - a selected record whose timestamp is at or before 1971 — **with no override at
147
+ all**. A timestamp that low is a broken clock, a failed copy or a restored
148
+ archive, and every age rule reads it as infinitely old and takes it first.
149
+ The fix is to repair or pin the record, not to confirm a number nobody
150
+ believes;
151
+ - a purge cutoff in the future, which means "everything older than the future";
152
+ - a selection over `maxDeletions`;
153
+ - a malformed `.crewhaus/retention.json` — the library throws rather than
154
+ falling back to a default, and the tool surfaces that: an enforcer that half
155
+ understands its policy must not guess;
156
+ - a store that cannot be enumerated;
157
+ - a `.crewhaus/sessions` or `.crewhaus/audit` that is a symlink leading out of
158
+ the workspace. `readdir` follows it and session-store unlinks inside the
159
+ target, so a sweep of a harness in the workspace would delete files that were
160
+ never in it. `allowDeleteAll` does not reach this.
161
+
162
+ **`KnowledgeSync`**
163
+
164
+ - a push from a harness that has not opted in (`.crewhaus/knowledge.json`);
165
+ - a push without `allowWithoutPiiRedaction`, see below;
166
+ - a shared store overlapping the harness's `.crewhaus`, which would re-ingest
167
+ its own pushes;
168
+ - a sync moving more than `maxArtifacts` (500 by default) — a poisoned shared
169
+ store is untrusted input, and so is a runaway push;
170
+ - a push or a pull whose write path leaves the workspace through a symlink. The
171
+ shared store is untrusted by this tool's own description, so a link planted at
172
+ `memories.jsonl` would otherwise have appended the harness's memories to
173
+ whatever it pointed at.
174
+
175
+ ## What this build does not do
176
+
177
+ Three things the CLI does around these operations need packages
178
+ `@crewhaus/tool-lifecycle` does not depend on. Each is named where it would
179
+ have happened, and the operations that depend on it refuse rather than
180
+ approximate.
181
+
182
+ **Audit-chain verification and the compliance-evidence bundle.**
183
+ `@crewhaus/audit-log` and `@crewhaus/compliance-controls` are not dependencies
184
+ here, so `HarnessRetire` supplies those two steps as `ok: false` with the
185
+ reason. The library's own gate then aborts the retirement, which is the correct
186
+ default: nothing should be moved on the strength of a chain nobody checked. Run
187
+ the `AuditVerify` tool (`@crewhaus/tool-crewhaus`) first, then pass
188
+ `acceptUnverified: true`. The tool never claims either check happened — a real
189
+ run reports both under `notPerformed`.
190
+
191
+ **Record-shape store migration.** `StoreMigrate` moves a store between roots at
192
+ the version it already has. Rewriting records into a new shape is
193
+ `migrateMemories` in `apps/cli`, over `@crewhaus/memory-store` and
194
+ `@crewhaus/migration-engine`; a package may not depend on an app, and those two
195
+ are not dependencies here. A version-changing migration is therefore refused by
196
+ name rather than silently performed as a copy, which would leave a caller
197
+ believing their store had been upgraded.
198
+
199
+ **PII redaction on push.** Production wires the knowledge redactor around
200
+ `@crewhaus/pii-redactor`. Without it this build runs the credential half —
201
+ masking, plus the strict post-mask rescan that *drops* an artifact still
202
+ looking like a secret — with an identity PII pass. Names, emails and phone
203
+ numbers are not removed. A push says so and requires
204
+ `allowWithoutPiiRedaction`. (The library's `IDENTITY_REDACTOR` is deliberately
205
+ not used: it never flags anything, so a memory holding an API key would be
206
+ pushed verbatim.)
207
+
208
+ **What a store migration leaves behind.** `runRetentionExport` carries
209
+ `.crewhaus/sessions` and `.crewhaus/audit`. Memories, prompts, graders, the
210
+ registry and the policy files are not part of it, so every other entry under
211
+ `.crewhaus` is listed in `notMigrated` — a migration that reports success while
212
+ leaving them behind is exactly the half-migration that puts a store in neither
213
+ shape. The source is never deleted, so it remains the rollback, and a re-run is
214
+ safe: the receipt says what was already there.
215
+
216
+ ## Two things this package does differently from the CLI
217
+
218
+ **Dropped secrets are counted, never echoed.** `formatPushReport` prints a
219
+ 60-character preview of every memory dropped for still carrying a credential —
220
+ those previews are the secret. This tool reports `droppedForSecrets` as a count
221
+ only. A test asserts the raw result string does not contain the fixture secret.
222
+
223
+ **A pulled memory is recognised on the next pull.** `applyPull` lands a memory
224
+ with an extra `shared:<harness>` tag, and the content hash covers text *and*
225
+ tags — so hashing what is on disk does not reproduce the shared record's hash.
226
+ `crewhaus knowledge pull` compares exactly that way, which means every pull
227
+ re-imports everything it pulled before and `shared.jsonl` grows on each run.
228
+ `KnowledgeSync` hashes a landed memory both ways (still with the library's
229
+ `memoryContentHash`), so a second pull brings in nothing. The undo is exactly
230
+ `applyPull`'s do: **one** trailing `shared:` tag comes back off, because one
231
+ went on. Stripping every `shared:`-prefixed tag is a different function, and it
232
+ is wrong for the record that already carried provenance of its own — one
233
+ harness pulled it and pushed it on — for which neither form reproduces the
234
+ shared hash. `index.test.ts` pins both: the plain case still holds one line
235
+ after two pulls, and the round-tripped record is neither reported missing after
236
+ the pull that landed it nor re-imported by the next one.
237
+
238
+ **A pulled grader fragment is recognised too.** `applyPull` writes one to
239
+ `graders.shared-<hash>.yaml`, which `readHarnessGraders` deliberately does not
240
+ look at — it reads the harness's *own* `graders.yaml`. Fragments a previous
241
+ pull landed are counted separately (matched by that landing name, hashed with
242
+ the library's `fragmentContentHash`), so a second pull does not report pulling
243
+ them again. Fragments are also verified after a pull, as a push verifies its
244
+ own: checking only the memories and calling the whole pull `verified` would be
245
+ a partial check reported as a total one.
246
+
247
+ ## Results
248
+
249
+ Every tool returns compact JSON with a `status` of `preview`, `applied`,
250
+ `refused` or `failed`, plus a `code` and a `reason` on the two unhappy ones. A
251
+ preview carries `nothingWasTouched: true`. Sizeable lists are reported as
252
+ `{ shown, total }` so a bounded result never implies a short one.
253
+
254
+ ## Limits
255
+
256
+ - **The opt-in marker gates a push, not a pull.** `crewhaus knowledge sync`
257
+ filters a whole fleet by `.crewhaus/knowledge.json` in both directions,
258
+ because it walks harnesses nobody named. A tool call names one directory, and
259
+ the risk there runs the other way — what leaves the harness — so the marker
260
+ is required to push and not to pull. A pulled record is still validated by
261
+ the library and counted if it is refused.
262
+ - **Single tenant.** The on-disk stores carry no tenant id, so everything here
263
+ operates on the local tenant, exactly as `@crewhaus/harness-lifecycle` does.
264
+ - **The archived-state directory name is mirrored, not imported.** The library
265
+ moves the live state to `<archive>/crewhaus-state` and does not export that
266
+ name. If it ever changes, `HarnessRetire` reports that it could not verify
267
+ the archive (it never reports a verified one it did not find), and a test
268
+ here fails on the rename.
269
+ - **A retirement cannot be resumed.** The library aborts before the destructive
270
+ step, so a failed retirement leaves the harness intact and is re-runnable,
271
+ but there is no half-state to pick up from — by design.
272
+ - **Verification is of bytes, not of meaning.** A verified archive is one whose
273
+ files hash the same as before the move. Whether the harness should have been
274
+ retired at all is not a question a hash can answer.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @crewhaus/tool-lifecycle — the back half of a harness's life, as
3
+ * deterministic tools: retirement, store migration, retention enforcement and
4
+ * knowledge sync.
5
+ *
6
+ * These four are the operations that are irreversible when they are wrong. A
7
+ * retirement moves a harness's durable state out and removes the live copy. A
8
+ * retention sweep deletes sessions on an age rule. A store migration writes
9
+ * one store's contents into another root. A knowledge push copies a harness's
10
+ * memories into a store other harnesses read. None of them can be undone by
11
+ * running them again with a better flag.
12
+ *
13
+ * Five properties hold across the package.
14
+ *
15
+ * 1. THE REAL LIBRARY. Every rule these tools enforce belongs to
16
+ * `@crewhaus/harness-lifecycle` — the retirement plan and its
17
+ * active-pin refusal, the retention age rule and its pins, windows and
18
+ * audit-chain exclusion, the shared-knowledge validation and the
19
+ * credential redaction. This package contributes the schema, the
20
+ * containment, the refusals and the result shape. It does not contain a
21
+ * second copy of any of those rules, which is the whole reason they were
22
+ * lifted out of `apps/cli` in the first place.
23
+ * 2. DRY RUN IS THE DEFAULT, AND IT IS THE SAME CODE. `dryRun` defaults to
24
+ * TRUE on all four tools: an irreversible operation should take an
25
+ * explicit `dryRun: false` to happen. The preview is produced by calling
26
+ * the same library function with its own dry-run flag, so the selection
27
+ * being previewed is the selection the real call acts on, not a parallel
28
+ * preview beside it. Where the two runs can still disagree — the library
29
+ * re-enumerates, and a store can change between the calls — the
30
+ * difference is computed and REPORTED rather than hidden.
31
+ * 3. A REFUSAL WHEN THE SELECTOR IS TOO WIDE. An age rule that selects
32
+ * every session in the store, a purge cutoff in the future, a record
33
+ * whose timestamp is epoch zero, a migration into a directory that
34
+ * already holds someone else's store, a retirement into an archive that
35
+ * already holds one: each is refused by name, with what it would have
36
+ * taken.
37
+ * 4. COULD NOT DETERMINE IS NOT NO. A directory that could not be listed is
38
+ * never reported as empty, an unreadable registry manifest is never
39
+ * reported as "no pins", an unverifiable archive is never reported as
40
+ * verified, and a step this build cannot perform reports that it did not
41
+ * perform it rather than returning success.
42
+ * 5. WHAT THIS BUILD CANNOT DO, IT SAYS. Three things the CLI does around
43
+ * these operations need packages this one does not depend on: verifying
44
+ * the audit chain, collecting a compliance-evidence bundle, and
45
+ * rewriting records into a new store version. Each is named where it
46
+ * would have happened, with the tool or command that does it, and the
47
+ * operations that depend on it are refused rather than approximated.
48
+ */
49
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
50
+ export declare const harnessRetire: RegisteredTool;
51
+ export declare const storeMigrate: RegisteredTool;
52
+ export declare const retentionEnforce: RegisteredTool;
53
+ export declare const knowledgeSync: RegisteredTool;
54
+ /** Every tool this package registers, in the order a catalog should list them. */
55
+ export declare const LIFECYCLE_TOOLS: ReadonlyArray<RegisteredTool>;