@cosyte/cli 0.0.5 → 0.1.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/CHANGELOG.md CHANGED
@@ -15,8 +15,424 @@ still do. Each entry was assigned to the release whose tag first contains it, re
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ **What 0.1 means for you.** This is the first release whose commands, output and exit codes we treat
19
+ as settled: command names, flags, the JSON output shapes, the exit-code contract (`0`, `1`, `2`,
20
+ `65`, `66`, `69`, `70`, `74`) and the diagnostic codes are the surface we keep stable. It covers the
21
+ `cosyte` command over all eight cosyte formats (`parse`, `validate`, `inspect`, `fmt`, `convert`,
22
+ `map-codes`, `redact` / `deid`, `completion`), value-free diagnostics with `--unsafe-show-values` as
23
+ the one opt-in, the `cosyte-mcp` stdio server and the programmatic `core` API. While the package is
24
+ below 1.0, a breaking change bumps the minor version and is called out here; a fix that changes no
25
+ output ships as a patch. Not covered yet: DICOM `parse` and `fmt`, C-CDA `parse`, and MLLP `fmt` and
26
+ `validate` (`CLI_FORMAT_UNSUPPORTED`), `validate --profile` (`CLI_NOT_IMPLEMENTED`), `redact` for
27
+ `astm`, `dicom`, `mllp` and `ncpdp`, a remote MCP transport and MCP tools for `redact` and
28
+ `map-codes`. The FHIR commands and `convert` use `@cosyte/fhir` as the peer of the optional
29
+ `@cosyte/transform`, and report `CLI_PARSER_UNAVAILABLE` (exit `69`) when a package manager leaves
30
+ it out.
31
+
32
+ ### Added
33
+
34
+ - **A downstream consumer that closes the CLI's stdout now ends the run quietly, under a documented
35
+ code of its own: `74` (`IOERR`, the value `sysexits.h` assigns `EX_IOERR`).** A pipeline into
36
+ `head`, a pager you quit, or any early-exiting reader gets the stable value-free
37
+ `CLI_OUTPUT_WRITE_FAILED` diagnostic and that exit code: no stack trace, no platform error text,
38
+ and no byte of the input on stderr. The contract is additive; no existing code is renamed,
39
+ repurposed or removed, and no existing code's meaning changes.
40
+ - **The code was chosen on a measurement, not a citation.** A write to a closed stdout is a Node
41
+ `EPIPE` on the stream, which is fatal when nothing is listening: measured here, the unguarded
42
+ shape prints a ten-frame stack trace and exits `1`. It is a handled condition the consumer owns,
43
+ so reporting it as `70` told an operator their pipeline had hit a bug in the CLI. The one
44
+ condition that moves onto the new code is that write failure itself, which previously reported
45
+ `70` on the record-stream path.
46
+ - **Neither write path reports a success over output that reached nobody.** The whole-result write
47
+ in the `cosyte` bin bypassed the guarded output sink altogether, and the record stream's sink
48
+ only consulted a flag the platform sets asynchronously, so a short enough run could have every
49
+ line enqueued before the closed consumer was reported. Either way a parse piped into a reader
50
+ that stopped early could print a summary and exit `0` over output that was never delivered.
51
+ Every chunk bound for stdout now waits for the platform's acknowledgement before the run may
52
+ resolve, so an undelivered result resolves to `74` whatever the command had computed, and the
53
+ summary describing that computed outcome is withdrawn with it.
54
+ - **`cosyte-mcp` terminates quietly when its client goes away.** A closed stdout used to reach the
55
+ process as an unhandled stream error, printing a Node stack trace naming this machine's install
56
+ paths. It is now a value-free diagnostic and the same exit code, and the server ends rather than
57
+ serving a channel nobody is reading.
58
+ - **A closed stderr cannot turn either termination into an unhandled error.** Both bins hear the
59
+ diagnostic channel's own error and fall back to the exit code as the whole signal.
60
+ - `test/pipe-close.test.ts` spawns real processes and closes their real streams, which nothing in
61
+ this suite did before: the existing coverage drove an injected sink inside the test process and
62
+ could say nothing about the process adapters. Its record-stream cases run at one and two records
63
+ on purpose, because a long stream fails on a later write and so is caught however the earlier
64
+ ones were handled, and `scripts/smoke.mjs` asks the same question of the PACKAGED bin under
65
+ plain `node`, where a faster start widens the window further.
66
+
67
+ - **The release pipeline now installs the packed tarball from outside this repository, and runs both
68
+ bins from that installed copy, before anything can publish.** `0.0.1` and `0.0.2` are on npm
69
+ permanently uninstallable, and the check that catches that shape was a sentence in a human
70
+ checklist: a `--dry-run` packs a tarball but never resolves its dependencies from a registry. It is
71
+ now the `install-gate` job in the release workflow, which the publishing job `needs:`, so a red
72
+ gate stops the publish rather than reporting on one that already happened.
73
+ - **It refuses a local-path dependency specifier before it packs anything.** A `file:`, `link:` or
74
+ bare filesystem path in `dependencies`, `optionalDependencies` or `peerDependencies` is what made
75
+ those two versions uninstallable, and it is now a refusal naming the field and the specifier.
76
+ `devDependencies` is deliberately exempt and reported rather than skipped silently: a consumer
77
+ never installs them, which is why this package can carry one on purpose.
78
+ - **Both bins are executed, not stat-ed.** `cosyte` must exit `0` from the installed copy, and
79
+ `cosyte-mcp`, a stdio server that never exits on its own, must not exit non-zero inside a bounded
80
+ window. A bin whose file is missing from the tarball is reported as a packaging defect, in words
81
+ that do not read like an install failure.
82
+ - **It fails closed.** An install that exceeds its time bound, a registry that does not answer and
83
+ a throw inside the gate each refuse with their own reason rather than warning and continuing. The
84
+ accepted cost is that a transient fault delays a correct release until the run is repeated.
85
+ - No dependency was added, no manifest field moved, and nothing a consumer receives changed.
86
+
87
+ - **Every MCP tool now publishes an `outputSchema`, and every tool result conforms to it.** An agent
88
+ calling a cosyte tool used to receive `structuredContent: { exit, ok }` with no schema to check it
89
+ against, so deciding whether a result held data or a diagnostic meant pattern-matching a text blob.
90
+ Each of the four tools now advertises an output schema and a `title` on `tools/list`, and every
91
+ dispatch path (success, negative verdict, hard failure, usage error, internal error) returns
92
+ structured content conforming to the schema its own tool declared.
93
+ - **The result carries `ok`, `status`, `exit`, `code` and `data`.** `status` is `success`,
94
+ `verdict` or `failed`, and it is the property that separates a negative verdict about the message
95
+ (the tool ran, the payload is present, the call is not an error) from a call that produced
96
+ nothing: the distinction no text blob could make reliably. `code` is the stable diagnostic code on
97
+ a failed call. `data` is the tool's own payload: the parsed model and warnings (or a record stream
98
+ for a multi-record input), the validation verdict and findings, the value-free structural summary,
99
+ or the converted `Bundle`.
100
+ - **On a failed call the structured result is value-free by construction**, not by review: every
101
+ property is drawn from a fixed set (the outcome vocabulary, the exit-code contract, the diagnostic
102
+ code registry), so no part of the caller's input can appear in it. One consequence is deliberate:
103
+ an unknown tool name is no longer echoed back, because a tool name is caller-supplied text like
104
+ any other argument.
105
+ - Tool names, tool count, `tools/list` ordering, input schemas, the exit-code contract, and the rule
106
+ that a parsed-but-invalid `validate` is a successful call are all unchanged, and no dependency was
107
+ added. The suite validates each emitted result against its own tool's declared schema with a
108
+ dependency-free checker that refuses any schema keyword it does not implement.
109
+
110
+ - **`redact` / `deid` produces a real de-identified copy, delegated whole to `@cosyte/deid`.** The
111
+ command that shipped as an honest `CLI_NOT_IMPLEMENTED` is wired. For `ccda`, `fhir`, `hl7` and
112
+ `x12` stdout carries the de-identified document (serialized exactly as `cosyte fmt` serializes that
113
+ format) and stderr carries the library's own value-free manifest, one line per locus with its
114
+ category, transform, structural path, count, disposition and stable code, plus the library's own
115
+ published output label and version. The CLI adds no policy, no locus map, no transform and no
116
+ fallback scrub, and asserts no de-identification standard of its own.
117
+ - **Every other outcome emits nothing at all**, because a partial pass offered as a de-identified
118
+ copy is the hazard the command exists to prevent. `astm`, `mllp` and `ncpdp` have no adapter in
119
+ that library: `CLI_NOT_IMPLEMENTED` (`69`). `dicom` is covered there, but its de-identified form
120
+ is a Part 10 byte stream this text stdout cannot carry: `CLI_FORMAT_UNSUPPORTED` (`65`), the
121
+ CLI's own limit, deliberately not blamed on the library. Any locus the library reports it could
122
+ not handle is the new `CLI_DEID_INCOMPLETE` diagnostic and exit `1`, with the blocked paths and
123
+ their codes named on stderr.
124
+ - **`@cosyte/deid` is an `optionalDependency`.** An install without it degrades to a value-free
125
+ `CLI_PARSER_UNAVAILABLE` (`69`), decided **before the input is read**, so a copy that cannot
126
+ de-identify never touches the bytes it cannot strip. No other command loads it: the boundary is
127
+ gated statically and observed at runtime in `test/deid-isolation.test.ts`.
128
+ - **Identifier surrogates are keyed with a per-invocation ephemeral key.** The library's default
129
+ policy pseudonymizes MRN / account / beneficiary numbers, and a keyed transform with no key is a
130
+ fatal there, never an unkeyed fallback. The CLI holds no key material and adds no key surface, so
131
+ it keys each run with a fresh random value: surrogates are consistent within one output and
132
+ deliberately not stable across runs. Stated on stderr and in the docs rather than discovered.
133
+ - **`redact` does not honour `--unsafe-show-values`.** The opt-in excerpt exists for debugging a
134
+ rejected parse; on this command an excerpt of the un-stripped input is precisely the leak it
135
+ exists to prevent, so its diagnostics stay value-free under every flag. The PHI-leak sentinel
136
+ matrix now carries a redact row per mode (covered, refused, blocked, library absent, `--format`
137
+ given and omitted, file and stdin).
138
+ - No published exit value moved and no existing `CLI_*` code was renamed. `redact` is still
139
+ deliberately absent from the MCP tool surface.
140
+
141
+ - **The two-file agent-guidance contract is now gated (`pnpm check:agent-notes`).** `CLAUDE.md` was
142
+ split from `documentation/agent-notes.md` on 2026-08-04, which made every anchor between them
143
+ load-bearing, and nothing checked them. `scripts/check-agent-notes.ts` now verifies that the
144
+ narrative file is tracked, that no section is emptied down to its heading (a container whose body is
145
+ its subsections is exempt and counted), and that every pointer at it resolves. It runs from
146
+ `test/scripts/agent-notes.test.ts`, so it rides the required `ci / verify` contexts and
147
+ `prepublishOnly` rather than adding a fourth workflow.
148
+ - **The matcher was derived by counting this tree, not by porting a sibling's.** Two pointer
149
+ spellings are live across these repositories, and porting without re-counting has produced a false
150
+ green twice: a qualified-only matcher would have covered 3 of `ncpdp`'s 38 pointers, and would
151
+ have matched nothing at all in `terminology` (42 bare, zero qualified) while still exiting 0.
152
+ **Measured here: every pointer is the qualified `documentation/agent-notes.md#<anchor>` form and no
153
+ bare pointer exists**, so the gate matches that form only. No count is written into the gate's
154
+ prose as a promise; the OK line prints all of them on every run, because it measures rather than
155
+ remembers.
156
+ - **A bare-form census keeps that scope honest.** Matching one spelling is safe only while the other
157
+ stays absent, so in **every opened file** each backticked `#<anchor>` span is enumerated: a
158
+ digits-only one is a pull-request reference and is counted and reported, and **any other one
159
+ refuses the run at exit 2** with an instruction to re-derive the matcher. Refusal rather than a
160
+ finding, because the tree has not necessarily broken but the evidence the scope rested on has.
161
+ A first draft scoped the census to the pair and justified that with tree files it claimed a
162
+ widening would red; **running it tree-wide proved none of them can, and that the only files that
163
+ could were the gate's own source and test**, which wrote bare spans out literally. That made the
164
+ narrow scope a self-exemption for the very files where a broken pointer would hide, so the samples
165
+ are now assembled from parts and the census covers the corpus. It closes the hole a pair-scoped
166
+ census leaves: a bare pointer in a third file was covered by neither the matcher nor the census.
167
+ - **Three claims were withdrawn after review rather than shipped.** The opening promise said the
168
+ gate catches "a rename"; it does not, because both halves match on **basename** and the directory
169
+ is never compared, so moving the file while pointers keep their prefix exits 0 with every rendered
170
+ link broken. That is now a narrowed promise and a disclosed miss rather than a bigger guard. A
171
+ **heading inside an HTML comment** mints a phantom anchor, which the list had omitted; it is now
172
+ disclosed and pinned. And a written-down count of sibling repos lacking the file was already
173
+ wrong, so **the number was dropped in favour of the class**.
174
+ - **Two further claims were withdrawn on later review passes, each arriving inside the fix for the
175
+ one before it.** The first fix asserted the HTML-comment miss is disclosed in "every sibling
176
+ copy"; that phrasing came from a review rather than from the tree, and it is false. `ncpdp`,
177
+ `terminology` and `astm` disclose it; `mllp`, `ccda`, `transform` and `docs` do not, and `mllp`
178
+ is the copy this gate's disclosed-miss block is transcribed from, so the shared ancestor never
179
+ carried the entry. The second fix then said the four that lack it are all owed it. Also false:
180
+ `mllp`, `ccda` and `transform` derive anchors by **slugging headings** and are owed it, while
181
+ `docs` resolves explicit `<a id>` anchors and cannot have the miss at all. **A claim about
182
+ another repository is not checkable from inside this one**, so it is measured against that
183
+ repository's source or not made.
184
+ - **It refuses rather than reporting green over a corpus it never opened.** There is no declared
185
+ scan root to be wrong about: the corpus is `git ls-files`, reconciled as **sets of paths**, and
186
+ zero pointers, zero tracked files, an unmerged path, a symlink, a non-regular file or two files
187
+ carrying the contract basename each refuse at exit 2. This repository has already shipped the
188
+ opposite defect once, in `phi-scan`, which printed `OK, no hits` over a root it never walked.
189
+ - **Every claim was watched to fail on a clone of the real tree** before the gate was believed: a
190
+ misspelled real anchor, an emptied real section, the narrative file deleted, a bare pointer that
191
+ resolves, a neutered matcher, and a tree with every pointer rewritten out of range. The last two
192
+ are the `terminology` scenario, and both refuse instead of going green.
193
+ - **Exit codes and corpus handling were re-derived from this repository, not inherited.** `0`, `1`
194
+ for a finding and `2` for a refusal come from `scripts/phi-scan.ts`. An unmerged path **refuses**
195
+ here even though `phi-scan` leaves that status unenumerated, because its reasoning (`git commit`
196
+ refuses an unmerged index) covers a staged route this gate does not have.
197
+ - **The NUL skip is a disclosed miss, not a pass**, and is required rather than tidy: the tree
198
+ tracks vendored `@cosyte/*` tarballs and a synthetic DICOM fixture, none readable as markdown or
199
+ editable to clear a red. The tell is the skipped count on the OK line. A draft claimed this
200
+ partition differs from `check-no-emdash.sh`'s; **that was false and was corrected by reading that
201
+ gate's own OK line.** Both key on an actual NUL byte; the wider set is git's own binary
202
+ classification, which is why neither gate may be reduced to `grep -I`.
203
+
204
+ - **`parse` has a documented input-size limit: 67108864 bytes (64 MiB) per invocation, refused as a
205
+ data error.** Node has two hard allocation ceilings (`buffer.constants.MAX_LENGTH` and
206
+ `buffer.constants.MAX_STRING_LENGTH`), and a legitimately large input that crossed one of them threw
207
+ past every handler and was reported as `CLI_INTERNAL` / exit `70`, which says "this is a bug in the
208
+ tool" about an input that is merely big. The CLI now declares its own limit far below both, checks it
209
+ against the **running byte count as the input arrives**, and refuses with a value-free
210
+ `CLI_INPUT_TOO_LARGE` naming the limit and exit `65`. The number is rendered from one constant into
211
+ `cosyte --help` and the command reference, and a test reds if the two ever disagree.
212
+ - The refusal fires before anything allocates memory proportional to the oversized input, which is
213
+ what makes it a refusal rather than a slower way to reach the same crash. The regression suite
214
+ proves it with sources that would run past `MAX_STRING_LENGTH` if anything drained them.
215
+ - The MLLP frame reader's own smaller default ceiling is raised to the CLI's limit on this path, so
216
+ the documented number is the binding one rather than an undocumented number underneath it.
217
+
218
+ ### Changed
219
+
220
+ - **The CLI now runs on the 0.1 releases of every `@cosyte` library it uses except `@cosyte/deid`.**
221
+ The two required dependencies, `@cosyte/hl7` and `@cosyte/terminology`, and the optional
222
+ `@cosyte/astm`, `@cosyte/ccda`, `@cosyte/dicom`, `@cosyte/mllp`, `@cosyte/ncpdp`,
223
+ `@cosyte/transform` and `@cosyte/x12` move from their `0.0.x` releases to `^0.1.0`, so an install
224
+ takes their `0.1.x` patch releases and stops before `0.2.0`. `@cosyte/deid` follows in the next
225
+ entry, because it changes what `redact` does. The CLI's commands, flags, exit codes and diagnostic
226
+ codes are unchanged.
227
+ - **`redact` now delegates to the 0.1 releases of `@cosyte/deid`, and refuses more HL7 messages.**
228
+ The optional `@cosyte/deid` moves from `^0.0.9` (which pinned exactly `0.0.9`) to `^0.1.0`. The
229
+ library's default policy is the one `redact` applies, unmodified, and under it:
230
+ - **An HL7 visit number or order number refuses the run.** `PV1-19`, and a placer or filler order
231
+ number (`ORC-2`/`ORC-3`, `OBR-2`/`OBR-3`), fall to the catch-all identifier category, which the
232
+ default policy blocks (`DEID_LOCUS_BLOCKED`). `redact` answers with its existing refusal:
233
+ `CLI_DEID_INCOMPLETE`, exit `1`, nothing on stdout, and each blocked locus named on stderr with
234
+ the policy still attributed to the library. An ADT message that carries a visit number, which
235
+ `redact` used to de-identify, is now refused. The CLI passes no policy of its own to turn the
236
+ block into a removal: the library's own profile API rejects that weakening.
237
+ - **Medical record, account and member numbers are removed** (`DEID_CATEGORY_REMOVED`) rather than
238
+ replaced by a keyed surrogate. The per-invocation ephemeral key and its stderr disclosure stay,
239
+ so any keyed transform the library applies is never unkeyed.
240
+ - **HL7 `MSH-7` and `EVN-2` timestamps are now generalized.** The `0.0.9` policy left them
241
+ unchanged; a guard in the suite reds if a delegate that does so is ever resolved again.
242
+ - No exit code, diagnostic code or flag changed. The README and the command reference, limitations
243
+ and troubleshooting pages say which inputs produce output and which are refused.
244
+ - **The `js-yaml` dependency override now covers the advisory's extended range.** The pin moves from
245
+ `4.2.0` on `>=4.0.0 <4.2.0` to `4.3.0` on `>=4.0.0 <4.3.0`, so a transitive resolution landing
246
+ inside the newly covered window is remediated instead of being admitted silently. The `esbuild`
247
+ override is untouched, and the lockfile records the new pin.
248
+ - **`pnpm-workspace.yaml` declares the install hardening this package relies on**: a
249
+ `minimumReleaseAge` of 1440 minutes, which is the cooling-off window that blunts a compromised
250
+ release, and a `trustPolicy` of `no-downgrade`.
251
+ - **The `packageManager` pin moves to a pnpm release that honours both keys.** The previous pin
252
+ predates them, so it would have ignored the settings file entirely: a file that decorates rather
253
+ than defends is worse than an absent one, because it reads as a control that is in place.
254
+ - **Nothing an install resolves changed beyond the override above.** The full pre-publish ladder
255
+ (clean, typecheck, lint, test, build and the types check) runs green on the new pin.
256
+ - **The always-read agent guide is inside its declared line budget, and nothing it said was lost.**
257
+ Every rule and every trap stays there as a one-line imperative carrying a resolving pointer, and
258
+ the sentences that explained each one moved into the narrative document verbatim. The two-file
259
+ contract gate checks on every run that each pointer still lands on a section with a body, so the
260
+ compression cannot quietly become a deletion.
261
+ - **The declared Node range now matches the release lines this package is tested on.**
262
+ `engines.node` narrows from `>=22.0.0` to `>=22.0.0 <26.0.0`. Node 26 is supported upstream and
263
+ was admitted by the old open-ended range while the test matrix exercises 22 and 24 only, so an
264
+ install on Node 26 was a combination nobody here had run and the manifest said nothing about it.
265
+ The claim narrows to the tested set rather than the tested set widening to the claim: every matrix
266
+ cell is a required status check whose name is fixed by a repository setting no commit can edit, so
267
+ adding a line is a change that has to start outside this repository.
268
+ - **The tested set is stated here now.** The CI caller passes the Node majors explicitly rather
269
+ than inheriting them from the shared pipeline's input default. The same two versions run and
270
+ every status-check name is byte-identical; what changed is that the set is readable and diffable
271
+ in this repository instead of resolved somewhere else.
272
+ - **A guard reds when the two drift apart again.** It derives the lines upstream still supports
273
+ from a release schedule committed beside it, evaluated at the run date rather than pinned to a
274
+ number typed into a test, and reports a supported line the range admits but the matrix skips, a
275
+ matrix cell the range refuses, a range admitting a line the schedule does not carry, and any
276
+ prose surface restating the range or the matrix differently. It refuses to report agreement over
277
+ an absent, empty or provenance-less schedule.
278
+ - **The installation prerequisite states the upper bound too**, so the published documentation and
279
+ the published manifest cannot disagree about which runtimes are supported.
280
+ - **The MCP text content block is now the serialized JSON of the structured result**, replacing the
281
+ command's raw stdout (on a success) or its stderr diagnostic (on a failure). A client that reads
282
+ only text now sees exactly the value a schema-aware client validates: there is one value, serialized
283
+ once, so the two channels cannot disagree. A client that read the text block expecting the bare
284
+ command output will find the same payload one level down, under `data`.
285
+ - **Multi-record `parse` output (`--ndjson` and MLLP) is now emitted record by record, as each record
286
+ is parsed**, instead of being accumulated and written once at the end. The first line reaches stdout
287
+ before the rest of the input has been read, so a bulk batch pipes into the next process instead of
288
+ waiting on the whole file. Per-record isolation and the exit-code contract are unchanged: a record
289
+ that fails to parse is still a value-free `{ record, error }` line, the stream still continues, and
290
+ any failed record still resolves the invocation to exit `65`.
291
+ - **A fatal condition part way through keeps the lines already written and still exits non-zero.**
292
+ A truncated MLLP stream is the visible case: the frames that completed have already been emitted
293
+ when the unterminated one is detected at end of stream, so `stdout` is no longer empty for that
294
+ input. It was never a success and still is not: the exit code carries the failure, and a partial
295
+ record stream is never reported as a complete one.
296
+ - **A downstream consumer that closes the pipe part way through is now a value-free
297
+ `CLI_OUTPUT_WRITE_FAILED`** rather than an unhandled write error. One write per record is a
298
+ failure surface a single write did not have.
299
+
300
+ - **`CLAUDE.md` narrative was relocated into `documentation/agent-notes.md` to make room for the gate's
301
+ rules.** The branch-protection, PHI-scanner-residual and em-dash blocks were compressed to their
302
+ imperatives; every trap keeps a one-line rule and a pointer, and the reasoning each one compresses
303
+ was already in the narrative file. No trap was deleted and no ceiling was raised.
304
+
18
305
  ### Fixed
19
306
 
307
+ - **The PHI scanner reported on a run that had enumerated a target and never read it.** A logged
308
+ `--allow-fixture` bypass withdraws a file after the scan has already named it on the command line,
309
+ and the run then reported on whatever was left as though the corpus were whole. Over a corpus whose
310
+ only violator was withdrawn, that reads as a clean verdict about a file nobody opened.
311
+ - **The run now refuses, with a status distinct from both the clean status and the hits status.**
312
+ The refusal names every enumerated path that went unread and points at the token-level allow-list
313
+ as the remedy, because declaring an identifier keeps the file inside the scan while withdrawing it
314
+ removes the only evidence the run could have had about it.
315
+ - **Hits are reported FIRST, always.** A refusal raised before the findings were written would
316
+ discard every hit the run did find, which turns a stricter gate into a quieter one. The report
317
+ runs first and the refusal changes only the exit status.
318
+ - **The question is answered as a set difference, never as a count.** A count counts the targets
319
+ that DID get read, so it reads healthy at the exact moment a withdrawal empties the corpus, and it
320
+ cannot say which paths were missed.
321
+ - **An honest run is untouched.** With nothing withdrawn, the read set is the enumerated set, so the
322
+ pre-commit hook, the whole-tree sweep and a plain scan of named paths keep the exit codes they
323
+ already had.
324
+ - **The exit-code contract labelled exit `2` as `EX_USAGE`, and `sysexits.h` assigns `EX_USAGE` the
325
+ value `64` (EXIT-CODE-SYSEXITS-LABEL).** The label sat in two places in `src/core/exit-codes.ts`,
326
+ the module's contract table and the `USAGE` member's JSDoc, so it compiled into `dist/*.d.ts` and
327
+ rendered on a consumer's hover. `README.md` and the concepts page grounded their whole table in
328
+ that header while listing `1` and `2`, which it does not define at all. A developer aligning their
329
+ own tooling to `sysexits.h` therefore learned a false fact about it from our documentation.
330
+ - **Exit `2` now carries no sysexits constant, and `1` and `2` are both stated as this CLI's own
331
+ values.** The four labels that were already correct (`EX_DATAERR` 65, `EX_NOINPUT` 66,
332
+ `EX_UNAVAILABLE` 69, `EX_SOFTWARE` 70) are untouched.
333
+ - **No published number moved, and no member of the exported `EXIT` map was added, removed or
334
+ renamed.** This is a documentation correction and nothing else: `2` is still `2`, every command
335
+ still exits with the status it exited with before, and the golden exit-code matrix is unchanged.
336
+ A consumer who hard-coded `EX_USAGE = 2` against this CLI keeps working.
337
+ - **`test/exit-code-docs.test.ts` reds if a sysexits constant is ever again attached to a number
338
+ that header does not assign it**, across every source file, the README and every published docs
339
+ page, naming the offending surface, line and code. It carries the header's own table, reports a
340
+ constant that table does not define rather than passing it through, and is proved against the
341
+ exact text that shipped before this correction.
342
+ - **`pnpm phi-scan`'s all-mode walk was rooted at `test/__fixtures__` and `src` only, so 89 of this
343
+ repository's 123 tracked files were scanned by NEITHER of its two routes
344
+ (PHI-SCAN-WALK-ROOT-SCOPE).** The walk now roots at **`src`, `test` and `scripts`**, which opens 72
345
+ tracked files instead of 34.
346
+ - **Measured back to back on the base commit, and re-derived for this repository rather than ported
347
+ from a sibling.** A dashed SSN and an off-domain address written into `test/planted.test.ts`, in
348
+ this repository's own inline-message shape (a whole HL7 message as one `.ts` string literal with
349
+ `\r` escapes between its segments), exited **0** with `OK, no hits` in all mode, while naming the
350
+ same file in paths mode reported both at **exit 1** over the same bytes. A file written under
351
+ `scripts/` behaved identically. Both routes now report both.
352
+ - **All 38 newly opened files were hand-read.** Every message literal is a placeholder and the only
353
+ SSN/email shapes anywhere are the scanner's own declared synthetic payload, so the gap was one of
354
+ **enumeration**, not a live exposure: the defect was that the gate could not see those files, so
355
+ nothing would have caught a real value if one appeared.
356
+ - **`test` REPLACES `test/__fixtures__` rather than joining it.** Roots must stay disjoint: each is
357
+ walked independently and the results concatenated, so a nested root would enumerate every file
358
+ beneath it twice and report each hit twice. The fixture directory is still watched, through the
359
+ observation rule's other condition, **wherever git tracks files under it** (here, seven); only the
360
+ root a refusal is filed under changed.
361
+ - **One cover was LOST, and it is stated rather than implied away:** where git tracks NOTHING under
362
+ `test/__fixtures__`, an empty one no longer refuses. As a declared root it refused by the
363
+ opened-nothing floor whatever git carried; as an ordinary directory it contributes no entry and the
364
+ reconciliation has no expected path to miss. A test pins it.
365
+ - **`scripts/` is a root because the scanner, its allow-list and its override log live there**, so
366
+ the one directory guaranteed to hold PHI-shaped text was the one nothing enumerated. All nine
367
+ files there were measured against the detector before the root was declared: no hits.
368
+ - **The cost of that root nobody would guess: the allow-list's own bytes.** The allow-list documents
369
+ `ID <value>` as a synthetic id "matching an SSN / MRN / member-id shape", and the dashed-SSN check
370
+ consults no allow-list, so an `ID` entry written in that dashed shape now reds the gate on the
371
+ allow-list itself. Latent today (the shipped file declares its only id in the `MRN-` form, and a
372
+ test pins that). **The remedy is to spell the id in a shape the check does not match**, never to
373
+ make that check consult the allow-list (which would delete a detection) and never to exempt the
374
+ file (which would leave the likeliest place for a real value unswept).
375
+ - **A narrowing that came with it:** `test/__fixtures__` is no longer a declared root, so a live or
376
+ dangling symbolic link at that path is now an enumerated entry and is refused outright, whatever
377
+ it points at. Only the three top-level roots can still be followed.
378
+ - **`test/scripts/phi-scan.test.ts` is exempt from the sweep, and is the only exempt path.** It
379
+ carries violator literals on purpose, as the positive half of the scanner's own tests. The
380
+ exemption is applied **after the read**, so the file still counts as observed and an unreadable
381
+ one still refuses; it is **scoped to the sweep**, so naming the file explicitly still reports
382
+ every hit; and it is **per path, never a pattern**.
383
+ - **What this does NOT buy, stated because a wider reading would be false:** the detector is still
384
+ the cross-cutting SSN + email floor, over 38 more files. Structured, field-level detection remains
385
+ unimplemented, and a test now pins that limit rather than leaving it as prose.
386
+ - **`--staged` is deliberately unchanged**, because widening it changes what a commit is blocked on.
387
+ The two routes therefore differ widely, and the scanner's own documentation says by how much.
388
+ - **Four claims about which directories are deliberately NOT scan roots are now checks rather than
389
+ prose**, after two of them were measured false: `docs-content/` and `.changeset/` each carry one
390
+ tracked non-markdown file, and the repository root is not clean under the detector (the `author`
391
+ field carries a real off-domain contact address). `documentation/` and `.github/` were correct.
392
+
393
+ - **`pnpm phi-scan` printed `OK, no hits` and exited 0 over a corpus it never opened
394
+ (PHI-SCAN-OBSERVED-NOTHING-IS-GLOBAL).** A declared scan root that the walk never observed is now
395
+ a refusal at **exit 2**, in the all-mode sweep CI runs. Each root's walk is reconciled against
396
+ `git ls-files`, and two independent conditions refuse: the root contributed **nothing**, or git
397
+ tracks an in-scope file under the root that the walk did not open.
398
+ - **Six states measured on this repository, every one of them previously exiting 0 with
399
+ `OK, no hits`:** the root missing; the root emptied; the root a **dangling** symbolic link; the
400
+ root a **live** symbolic link to a directory outside the repository; a single tracked fixture
401
+ removed from the working tree with the rest of the root still opened; and `src` moved away.
402
+ - **The dangling case is why a kind check cannot stand in for this rule.** `existsSync` follows
403
+ the link and answers false, so `walk()` returns before `readdirSync` and the existing
404
+ not-a-regular-file refusal never fires. Nothing about the entry is ever inspected, so no check
405
+ on its kind can reach it. Refusing on what was **observed** needs no opinion about the entry.
406
+ - **A denominator is deliberately not what this is.** A count counts the roots and the files that
407
+ did exist, so a healthy-looking total is exactly what a starved root produces. This scanner
408
+ prints no file count and one was not added.
409
+ - **Existence is not observation, which is why both conditions ship.** Refusing only a missing
410
+ root leaves the emptied one open; refusing only an empty result leaves the swapped one open,
411
+ because a root pointed at another directory opens plenty. Neither subsumes the other.
412
+ - **Exit 2 was derived from this scanner's own contract, not ported from a sibling.** `1` means
413
+ "hits found" here; `walk()` already raises an unreadable root as an invocation failure, and a
414
+ root replaced by a regular file already exits 2 through `readdirSync`. Sibling scanners disagree
415
+ on this code, and carrying one across would have been the defect.
416
+ - **`git ls-files` failing refuses rather than answering the empty set**, because an empty answer
417
+ is indistinguishable from "this root tracks nothing" and would switch the rule off in silence.
418
+ - **The rule is one-directional on purpose.** A tracked in-scope file the walk missed refuses; an
419
+ untracked working-tree file the walk found does not, because scanning more than git carries is
420
+ the safe direction.
421
+ - **Scope, stated rather than left to be inferred:** all-mode only. `--staged` is a diff and not a
422
+ corpus, and widening it would change what a **commit** is blocked on, which is a separate
423
+ decision. Naming paths explicitly is unchanged.
424
+ - **Narrowed, not closed, and still disclosed in the module header:** a scan root that is itself a
425
+ live link is still followed. It is now refused whenever git tracks an in-scope file under the
426
+ root that the link's target does not also carry **at the same relative path**, so a link to an
427
+ unrelated directory refuses; the reconciliation compares path **sets** rather than the bytes git
428
+ carries at those paths, so a target mirroring the tracked names still passes at exit 0, and a
429
+ root git tracks nothing under is the degenerate case of that rather than the whole of it. An
430
+ **ancestor** of a scan root remains out of the staged route's scope, and paths mode still follows
431
+ a link a caller names.
432
+ - **A present-but-unreadable `phi-scan-overrides.md` exited 1, the code reserved for "hits found".**
433
+ `loadOverrideLog` threw a raw filesystem error past every handler while its sibling reader,
434
+ `loadAllowList`, had already been wrapped. It now exits 2 with a diagnostic. A caller branching on
435
+ the exit code read a broken invocation as a PHI finding.
20
436
  - **A red pre-publish gate showed a red X and merged anyway, because `ci / prepublish` was a
21
437
  required check nowhere (CI-REQUIRED-CHECKS).** The shared pipeline this repo calls grew a
22
438
  `prepublish` job on 2026-08-05 (`cosyte/.github#35`, `6142ac4`; its second layer defaulted on in