@cosyte/cli 0.0.6 → 0.1.1

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,332 @@ 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
+ - **Published through npm trusted publishing, with provenance.** The code is unchanged from 0.1.0:
221
+ the commands, flags, output, exit codes and diagnostic codes are the same. Only the README differs.
222
+ - **The CLI now runs on the 0.1 releases of every `@cosyte` library it uses except `@cosyte/deid`.**
223
+ The two required dependencies, `@cosyte/hl7` and `@cosyte/terminology`, and the optional
224
+ `@cosyte/astm`, `@cosyte/ccda`, `@cosyte/dicom`, `@cosyte/mllp`, `@cosyte/ncpdp`,
225
+ `@cosyte/transform` and `@cosyte/x12` move from their `0.0.x` releases to `^0.1.0`, so an install
226
+ takes their `0.1.x` patch releases and stops before `0.2.0`. `@cosyte/deid` follows in the next
227
+ entry, because it changes what `redact` does. The CLI's commands, flags, exit codes and diagnostic
228
+ codes are unchanged.
229
+ - **`redact` now delegates to the 0.1 releases of `@cosyte/deid`, and refuses more HL7 messages.**
230
+ The optional `@cosyte/deid` moves from `^0.0.9` (which pinned exactly `0.0.9`) to `^0.1.0`. The
231
+ library's default policy is the one `redact` applies, unmodified, and under it:
232
+ - **An HL7 visit number or order number refuses the run.** `PV1-19`, and a placer or filler order
233
+ number (`ORC-2`/`ORC-3`, `OBR-2`/`OBR-3`), fall to the catch-all identifier category, which the
234
+ default policy blocks (`DEID_LOCUS_BLOCKED`). `redact` answers with its existing refusal:
235
+ `CLI_DEID_INCOMPLETE`, exit `1`, nothing on stdout, and each blocked locus named on stderr with
236
+ the policy still attributed to the library. An ADT message that carries a visit number, which
237
+ `redact` used to de-identify, is now refused. The CLI passes no policy of its own to turn the
238
+ block into a removal: the library's own profile API rejects that weakening.
239
+ - **Medical record, account and member numbers are removed** (`DEID_CATEGORY_REMOVED`) rather than
240
+ replaced by a keyed surrogate. The per-invocation ephemeral key and its stderr disclosure stay,
241
+ so any keyed transform the library applies is never unkeyed.
242
+ - **HL7 `MSH-7` and `EVN-2` timestamps are now generalized.** The `0.0.9` policy left them
243
+ unchanged; a guard in the suite reds if a delegate that does so is ever resolved again.
244
+ - No exit code, diagnostic code or flag changed. The README and the command reference, limitations
245
+ and troubleshooting pages say which inputs produce output and which are refused.
246
+ - **The `js-yaml` dependency override now covers the advisory's extended range.** The pin moves from
247
+ `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
248
+ inside the newly covered window is remediated instead of being admitted silently. The `esbuild`
249
+ override is untouched, and the lockfile records the new pin.
250
+ - **`pnpm-workspace.yaml` declares the install hardening this package relies on**: a
251
+ `minimumReleaseAge` of 1440 minutes, which is the cooling-off window that blunts a compromised
252
+ release, and a `trustPolicy` of `no-downgrade`.
253
+ - **The `packageManager` pin moves to a pnpm release that honours both keys.** The previous pin
254
+ predates them, so it would have ignored the settings file entirely: a file that decorates rather
255
+ than defends is worse than an absent one, because it reads as a control that is in place.
256
+ - **Nothing an install resolves changed beyond the override above.** The full pre-publish ladder
257
+ (clean, typecheck, lint, test, build and the types check) runs green on the new pin.
258
+ - **The always-read agent guide is inside its declared line budget, and nothing it said was lost.**
259
+ Every rule and every trap stays there as a one-line imperative carrying a resolving pointer, and
260
+ the sentences that explained each one moved into the narrative document verbatim. The two-file
261
+ contract gate checks on every run that each pointer still lands on a section with a body, so the
262
+ compression cannot quietly become a deletion.
263
+ - **The declared Node range now matches the release lines this package is tested on.**
264
+ `engines.node` narrows from `>=22.0.0` to `>=22.0.0 <26.0.0`. Node 26 is supported upstream and
265
+ was admitted by the old open-ended range while the test matrix exercises 22 and 24 only, so an
266
+ install on Node 26 was a combination nobody here had run and the manifest said nothing about it.
267
+ The claim narrows to the tested set rather than the tested set widening to the claim: every matrix
268
+ cell is a required status check whose name is fixed by a repository setting no commit can edit, so
269
+ adding a line is a change that has to start outside this repository.
270
+ - **The tested set is stated here now.** The CI caller passes the Node majors explicitly rather
271
+ than inheriting them from the shared pipeline's input default. The same two versions run and
272
+ every status-check name is byte-identical; what changed is that the set is readable and diffable
273
+ in this repository instead of resolved somewhere else.
274
+ - **A guard reds when the two drift apart again.** It derives the lines upstream still supports
275
+ from a release schedule committed beside it, evaluated at the run date rather than pinned to a
276
+ number typed into a test, and reports a supported line the range admits but the matrix skips, a
277
+ matrix cell the range refuses, a range admitting a line the schedule does not carry, and any
278
+ prose surface restating the range or the matrix differently. It refuses to report agreement over
279
+ an absent, empty or provenance-less schedule.
280
+ - **The installation prerequisite states the upper bound too**, so the published documentation and
281
+ the published manifest cannot disagree about which runtimes are supported.
282
+ - **The MCP text content block is now the serialized JSON of the structured result**, replacing the
283
+ command's raw stdout (on a success) or its stderr diagnostic (on a failure). A client that reads
284
+ only text now sees exactly the value a schema-aware client validates: there is one value, serialized
285
+ once, so the two channels cannot disagree. A client that read the text block expecting the bare
286
+ command output will find the same payload one level down, under `data`.
287
+ - **Multi-record `parse` output (`--ndjson` and MLLP) is now emitted record by record, as each record
288
+ is parsed**, instead of being accumulated and written once at the end. The first line reaches stdout
289
+ before the rest of the input has been read, so a bulk batch pipes into the next process instead of
290
+ waiting on the whole file. Per-record isolation and the exit-code contract are unchanged: a record
291
+ that fails to parse is still a value-free `{ record, error }` line, the stream still continues, and
292
+ any failed record still resolves the invocation to exit `65`.
293
+ - **A fatal condition part way through keeps the lines already written and still exits non-zero.**
294
+ A truncated MLLP stream is the visible case: the frames that completed have already been emitted
295
+ when the unterminated one is detected at end of stream, so `stdout` is no longer empty for that
296
+ input. It was never a success and still is not: the exit code carries the failure, and a partial
297
+ record stream is never reported as a complete one.
298
+ - **A downstream consumer that closes the pipe part way through is now a value-free
299
+ `CLI_OUTPUT_WRITE_FAILED`** rather than an unhandled write error. One write per record is a
300
+ failure surface a single write did not have.
301
+
302
+ - **`CLAUDE.md` narrative was relocated into `documentation/agent-notes.md` to make room for the gate's
303
+ rules.** The branch-protection, PHI-scanner-residual and em-dash blocks were compressed to their
304
+ imperatives; every trap keeps a one-line rule and a pointer, and the reasoning each one compresses
305
+ was already in the narrative file. No trap was deleted and no ceiling was raised.
306
+
18
307
  ### Fixed
19
308
 
309
+ - **The PHI scanner reported on a run that had enumerated a target and never read it.** A logged
310
+ `--allow-fixture` bypass withdraws a file after the scan has already named it on the command line,
311
+ and the run then reported on whatever was left as though the corpus were whole. Over a corpus whose
312
+ only violator was withdrawn, that reads as a clean verdict about a file nobody opened.
313
+ - **The run now refuses, with a status distinct from both the clean status and the hits status.**
314
+ The refusal names every enumerated path that went unread and points at the token-level allow-list
315
+ as the remedy, because declaring an identifier keeps the file inside the scan while withdrawing it
316
+ removes the only evidence the run could have had about it.
317
+ - **Hits are reported FIRST, always.** A refusal raised before the findings were written would
318
+ discard every hit the run did find, which turns a stricter gate into a quieter one. The report
319
+ runs first and the refusal changes only the exit status.
320
+ - **The question is answered as a set difference, never as a count.** A count counts the targets
321
+ that DID get read, so it reads healthy at the exact moment a withdrawal empties the corpus, and it
322
+ cannot say which paths were missed.
323
+ - **An honest run is untouched.** With nothing withdrawn, the read set is the enumerated set, so the
324
+ pre-commit hook, the whole-tree sweep and a plain scan of named paths keep the exit codes they
325
+ already had.
326
+ - **The exit-code contract labelled exit `2` as `EX_USAGE`, and `sysexits.h` assigns `EX_USAGE` the
327
+ value `64` (EXIT-CODE-SYSEXITS-LABEL).** The label sat in two places in `src/core/exit-codes.ts`,
328
+ the module's contract table and the `USAGE` member's JSDoc, so it compiled into `dist/*.d.ts` and
329
+ rendered on a consumer's hover. `README.md` and the concepts page grounded their whole table in
330
+ that header while listing `1` and `2`, which it does not define at all. A developer aligning their
331
+ own tooling to `sysexits.h` therefore learned a false fact about it from our documentation.
332
+ - **Exit `2` now carries no sysexits constant, and `1` and `2` are both stated as this CLI's own
333
+ values.** The four labels that were already correct (`EX_DATAERR` 65, `EX_NOINPUT` 66,
334
+ `EX_UNAVAILABLE` 69, `EX_SOFTWARE` 70) are untouched.
335
+ - **No published number moved, and no member of the exported `EXIT` map was added, removed or
336
+ renamed.** This is a documentation correction and nothing else: `2` is still `2`, every command
337
+ still exits with the status it exited with before, and the golden exit-code matrix is unchanged.
338
+ A consumer who hard-coded `EX_USAGE = 2` against this CLI keeps working.
339
+ - **`test/exit-code-docs.test.ts` reds if a sysexits constant is ever again attached to a number
340
+ that header does not assign it**, across every source file, the README and every published docs
341
+ page, naming the offending surface, line and code. It carries the header's own table, reports a
342
+ constant that table does not define rather than passing it through, and is proved against the
343
+ exact text that shipped before this correction.
20
344
  - **`pnpm phi-scan`'s all-mode walk was rooted at `test/__fixtures__` and `src` only, so 89 of this
21
345
  repository's 123 tracked files were scanned by NEITHER of its two routes
22
346
  (PHI-SCAN-WALK-ROOT-SCOPE).** The walk now roots at **`src`, `test` and `scripts`**, which opens 72
@@ -473,7 +797,7 @@ node_modules/@cosyte/cli/vendor/cosyte-fhir-0.0.0.tgz`. The siblings are now rea
473
797
  provenance/OIDC, the vendored-`file:`→npm dep swap, and the two standing founder stops).
474
798
 
475
799
  - **Phase 6: six more formats + streaming + shell completion (ADR 0025).** The `cosyte` CLI now wraps
476
- **all eight cosyte formats**, routed through a single lazy **per-format adapter registry**
800
+ **all eight Cosyte formats**, routed through a single lazy **per-format adapter registry**
477
801
  (`src/core/parsers.ts`) that replaces the old per-command `hl7 ? : fhir` branches and makes support
478
802
  **per (format, operation)**. An unsupported (format, op) is a value-free `CLI_FORMAT_UNSUPPORTED`,
479
803
  never a fake (ADR 0018).
@@ -656,7 +980,7 @@ parse` invocation never loads it; the core works with the SDK absent (`--omit=op
656
980
  repo for this character, find the places it is a value rather than punctuation and convert those by
657
981
  hand, to a word, first.
658
982
 
659
- - **No cosyte surface in this repo uses an em dash any more, and a CI gate keeps it that way.** The
983
+ - **No Cosyte surface in this repo uses an em dash any more, and a CI gate keeps it that way.** The
660
984
  brand rule (`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014`
661
985
  outright and names commit messages explicitly. Measured byte-level over **all 124 tracked files**,
662
986
  not over markdown alone: **659 occurrences across 87 files**, all as the literal character and none
package/README.md CHANGED
@@ -7,12 +7,11 @@
7
7
 
8
8
  # @cosyte/cli
9
9
 
10
- > The **cosyte CLI**: a PHI-safe developer front door over the `@cosyte/*` healthcare parsers.
10
+ > The **Cosyte CLI**: a PHI-safe developer front door over the `@cosyte/*` healthcare parsers.
11
11
 
12
12
  `@cosyte/cli` is a **`bin` package**: its primary artifact is the `cosyte` command on your `PATH`. Pipe
13
13
  a raw message from a hospital feed into the terminal and get typed, structured JSON back in one line,
14
- without writing code, without reading the spec, and **without ever being handed a confident wrong value
15
- or a silent success on a malformed message**.
14
+ without writing code and without reading the spec.
16
15
 
17
16
  ```bash
18
17
  cat adt.hl7 | cosyte parse -
@@ -48,20 +47,19 @@ releases, not a fault in any command.
48
47
  sibling packages are now declared as real npm ranges, and the resulting tarball has been installed from
49
48
  outside this repository and exercised, which is the check a `npm publish --dry-run` cannot perform.
50
49
 
51
- **One dependency could not be made real, and it costs you FHIR support.** `@cosyte/fhir` is not on the
52
- npm registry, so it cannot be a dependency of this package at all; `@cosyte/transform` is on npm but
53
- requires it, so npm skips that too. In an installed copy:
54
-
55
- - FHIR `parse` / `inspect` / `fmt` / `validate` and the `convert` command report a value-free
56
- `CLI_PARSER_UNAVAILABLE` and exit `69`. They never guess, and they never blame your input.
57
- - HL7 v2, `map-codes`, and the six breadth formats (X12, C-CDA, DICOM, NCPDP, ASTM, MLLP) all work.
58
-
59
- To use the FHIR commands today, run the CLI from a source checkout (`pnpm install && pnpm build`, then
60
- invoke `dist/bin/cosyte.mjs`), where the FHIR library is supplied locally.
61
-
62
- > **Status:** pre-alpha (`0.0.x`). `0.0.1` and `0.0.2` are on npm and **cannot be installed** (see
63
- > above); a later version fixes that. The `cosyte` command wraps **all eight
64
- > cosyte formats** (**HL7 v2**, **FHIR R4**, **X12**, **ASTM**, **NCPDP SCRIPT**, **C-CDA**, **DICOM**,
50
+ **FHIR support arrives through a peer, not a declared dependency.** `@cosyte/fhir` is not in this
51
+ package's manifest. `npm install` brings it in as the peer dependency of `@cosyte/transform`, one of
52
+ this package's optional dependencies, and the FHIR `parse` / `inspect` / `fmt` / `validate` commands
53
+ and `convert` use that copy. Install with optional dependencies, as `npm install` does by default. If
54
+ a library a command needs is still missing, for example a peer your package manager did not install,
55
+ the command reports a value-free `CLI_PARSER_UNAVAILABLE` and exits `69`: it never guesses, and it
56
+ never blames your input. HL7 v2 and `map-codes` run on the two hard dependencies, `@cosyte/hl7` and
57
+ `@cosyte/terminology`.
58
+
59
+ > **Status:** `0.1`. While the package is below 1.0, a breaking change ships in a minor version and is
60
+ > called out in the changelog. `0.0.1` and `0.0.2` are on npm and **cannot be installed** (see above);
61
+ > every later version installs. The `cosyte` command wraps **all eight
62
+ > Cosyte formats** (**HL7 v2**, **FHIR R4**, **X12**, **ASTM**, **NCPDP SCRIPT**, **C-CDA**, **DICOM**,
65
63
  > and **MLLP**) plus the `@cosyte/transform` and `@cosyte/terminology` higher-layer libraries, with
66
64
  > conservative content-format autodetection and a documented exit-code contract:
67
65
  >
@@ -77,8 +75,9 @@ invoke `dist/bin/cosyte.mjs`), where the FHIR library is supplied locally.
77
75
  > free issues on stderr, and a non-zero exit on an error-severity conversion issue.
78
76
  > - **`map-codes`**: translate a code through a BYO FHIR ConceptMap via `@cosyte/terminology`; the
79
77
  > target coding(s) on stdout, or a value-free unmapped signal + exit `1`.
80
- > - **`redact` / `deid`**: gated to an honest `CLI_NOT_IMPLEMENTED` (exit `69`) until the CLI wires
81
- > `@cosyte/deid`; it never reads the input and never emits a partial scrub dressed up as de-identified.
78
+ > - **`redact` / `deid`**: a de-identified copy on stdout for `ccda`, `fhir`, `hl7`, `x12`, delegated
79
+ > whole to `@cosyte/deid`, with that library's value-free manifest on stderr; every other format is a
80
+ > typed refusal and it never emits a partial scrub dressed up as de-identified.
82
81
  > - **`completion <bash|zsh|fish>`**: print a static shell completion script.
83
82
  >
84
83
  > **Support is honest per (format, operation)**: not every parser faithfully supports every command, so
@@ -94,20 +93,22 @@ invoke `dist/bin/cosyte.mjs`), where the FHIR library is supplied locally.
94
93
  > described above, which is why `0.0.1` and `0.0.2` published green and still cannot be installed: a
95
94
  > dry-run builds the tarball but never resolves it from a registry. **That swap has now been made**,
96
95
  > and installing the packed tarball from outside the repository is a release step in its own right.
97
- > `@cosyte/fhir` is the one dependency it could not cover, which is why FHIR support is absent from an
98
- > installed copy rather than merely deferred. See [RELEASING.md](./RELEASING.md).
96
+ > `@cosyte/fhir` reaches an installed copy as a peer of `@cosyte/transform` (see above). See
97
+ > [RELEASING.md](./RELEASING.md).
99
98
 
100
99
  ## Run it
101
100
 
102
- > **These commands do not work yet, and the reason is now only that no fixed version has shipped.**
103
- > The newest version on npm is `0.0.2`, and it is one of the two that cannot be installed. The
104
- > packaging defect is fixed in this repository and proven by installing the packed tarball outside it,
105
- > but a published version is immutable, so the fix reaches you only in the next release. Until then,
106
- > run it from a source checkout: `pnpm install && pnpm build`, then invoke `dist/bin/cosyte.mjs`.
101
+ Requires Node `>=22.0.0 <26.0.0`. This block runs as-is in an empty directory: it installs the CLI,
102
+ writes a short synthetic HL7 v2 message, and reads it back.
107
103
 
108
104
  ```bash
109
105
  npm install -g @cosyte/cli # put `cosyte` on your PATH
110
- cosyte parse message.hl7 # format autodetected → HL7 v2
106
+
107
+ # A synthetic ADT^A01 admit. HL7 v2 ends every segment with a carriage return (\r).
108
+ printf 'MSH|^~\\&|SENDINGAPP|SENDINGFAC|RECEIVINGAPP|RECEIVINGFAC|20240101120000||ADT^A01|MSG00001|P|2.5\rEVN|A01|20240101120000\rPID|1||MRN-000123^^^HOSP^MR||ZZSENTINELLAST^ZZSENTINELFIRST^M||19800101|M\rPV1|1|I|WARD^101^A\r' > message.hl7
109
+
110
+ cosyte parse message.hl7 # format autodetected → HL7 v2, typed JSON on stdout
111
+ cosyte inspect message.hl7 # a value-free summary: type, version, segment counts
111
112
  ```
112
113
 
113
114
  Or without installing, using `npx`:
@@ -146,7 +147,15 @@ astm | ncpdp | ccda | dicom | mllp`.
146
147
  **MLLP** stream, one record per frame, or any input under **`--ndjson`**, one record per non-empty
147
148
  line) streams as **NDJSON**, one `{ record, format, model, warnings }` line each, with per-record
148
149
  isolation: a record that fails to parse becomes a value-free `{ record, error }` line and the stream
149
- continues; the overall exit is a data error (`65`) if any record failed.
150
+ continues; the overall exit is a data error (`65`) if any record failed. Each line is written **as
151
+ its record is parsed**, before the rest of the input has been read, so a batch pipes straight into
152
+ the next process.
153
+
154
+ **Input size.** `parse` reads at most **67108864 bytes (64 MiB)** per invocation, the number
155
+ `cosyte --help` prints. A larger input is a value-free `CLI_INPUT_TOO_LARGE` refusal naming that
156
+ limit, with the data-error exit code (`65`) and never the internal-error code: split the input and
157
+ re-run. Since records are emitted as they are parsed, a refusal can land after some records have
158
+ already reached stdout; the exit code, not the output, says the run did not complete.
150
159
 
151
160
  Support is honest **per (format, operation)**: `x12`/`astm`/`ncpdp` support all of parse/inspect/fmt/
152
161
  validate; `ccda` supports inspect/fmt/validate (parse deferred); `dicom` supports inspect/validate
@@ -230,20 +239,39 @@ valid JSON or not a loadable ConceptMap is a `CLI_MAP_INVALID` data error (`65`)
230
239
 
231
240
  ## The exit-code contract
232
241
 
233
- Every command is safe to branch on in CI. The exit code carries the outcome (`sysexits.h`):
234
-
235
- | Code | Meaning |
236
- | ---- | ---------------------------------------------------------- |
237
- | `0` | success / **valid** (`validate`) |
238
- | `1` | **invalid**: `validate` found a parseable-but-bad message |
239
- | `2` | usage error (unknown flag, missing argument) |
240
- | `65` | data error (unparseable input, or format undetected) |
241
- | `66` | no input (missing/unreadable file) |
242
- | `69` | unavailable (a capability is not yet built, e.g. `redact`) |
243
- | `70` | internal error (a bug) |
242
+ Every command is safe to branch on in CI. The exit code carries the outcome. `0` is the conventional
243
+ success status and the codes from `65` up are the values the Unix `sysexits.h` header assigns to the
244
+ conditions they name; `1` and `2` are this CLI's own values, because that header defines no constant
245
+ for either:
246
+
247
+ | Code | Meaning |
248
+ | ---- | ------------------------------------------------------------------------------------------------------------------- |
249
+ | `0` | success / **valid** (`validate`) |
250
+ | `1` | **invalid**: `validate` found a parseable-but-bad message, or `redact` could not de-identify every locus |
251
+ | `2` | usage error (unknown flag, missing argument) |
252
+ | `65` | data error (unparseable input, format undetected, or input past the size limit) |
253
+ | `66` | no input (missing/unreadable file) |
254
+ | `69` | unavailable (a capability is not wired for this input, e.g. `redact` on a format `@cosyte/deid` has no adapter for) |
255
+ | `70` | internal error (a bug) |
256
+ | `74` | output error: the output stream closed before the CLI finished writing (a consumer that exited early) |
244
257
 
245
258
  The load-bearing rule: the CLI **never prints a reassuring line and exits `0`** on input it could not
246
- handle, or on an invalid message.
259
+ handle, on an invalid message, or on output that never reached its consumer.
260
+
261
+ ### A consumer that goes away
262
+
263
+ Piping into a reader that stops early is ordinary shell usage (a pager you quit, a `head` that has
264
+ seen enough), and it is not an error in your input:
265
+
266
+ ```bash
267
+ cosyte parse bulk.ndjson --ndjson | head -3 # three lines, then exit 74, and a value-free note
268
+ ```
269
+
270
+ The CLI terminates quietly under `74`: no stack trace, no platform error text, and no byte of the
271
+ input on stderr, just the stable `CLI_OUTPUT_WRITE_FAILED` code. Exit `74` is deliberately not the
272
+ internal-error code: nothing went wrong inside the CLI, the answer simply did not reach you. The
273
+ same holds when the whole result is one write, so a run whose output never arrived is never
274
+ reported as a success.
247
275
 
248
276
  ## PHI posture
249
277
 
@@ -268,15 +296,47 @@ cosyte parse broken.hl7 --format hl7 --unsafe-show-values # appends a bounded i
268
296
 
269
297
  ## `cosyte redact` / `cosyte deid`
270
298
 
271
- De-identification is the one operation whose _job_ is to strip PHI. It is **not implemented yet, on
272
- purpose.** It belongs to [`@cosyte/deid`](https://github.com/cosyte/deid). That package is now
273
- published, but the CLI does not wire it yet, and the wrapped parsers expose no de-identification API of
274
- their own. A built-in "minimal Safe-Harbor" pass over only
275
- the obvious fields would leave PHI behind and _look_ de-identified while silently under-redacting: the
276
- exact false-safety hazard `redact` exists to avoid. So `cosyte redact <file>` is an honest, typed
277
- `CLI_NOT_IMPLEMENTED` (exit `69`, `EX_UNAVAILABLE`) that **never reads your input** and **never emits a
278
- partial scrub dressed up as safe**. It will produce a real de-identified copy once the CLI wires
279
- `@cosyte/deid` and that integration is vetted.
299
+ De-identification is the one operation whose _job_ is to strip identifiers, and the CLI **owns none of
300
+ the policy**. It belongs to [`@cosyte/deid`](https://github.com/cosyte/deid): the CLI locates nothing,
301
+ transforms nothing, and has no fallback scrub, because a built-in "minimal Safe-Harbor" pass over only
302
+ the obvious fields would leave PHI behind and _look_ de-identified while silently under-redacting, the
303
+ exact false-safety hazard `redact` exists to avoid.
304
+
305
+ ```bash
306
+ cosyte redact adt.hl7 > clean.hl7 # stdout: the de-identified message. stderr: what was touched
307
+ echo $? # 0: clean.hl7 is the copy. 1: clean.hl7 is empty, see stderr
308
+ ```
309
+
310
+ **Covered formats: `ccda`, `fhir`, `hl7`, `x12`** (what the library covers and this CLI can serialize
311
+ onto a text stdout). Anything else emits nothing at all:
312
+
313
+ | Outcome | Diagnostic | Exit |
314
+ | ------------------------------------------------------------------------------------ | ------------------------ | ---- |
315
+ | every locus handled | the manifest | `0` |
316
+ | a locus the library reports it could not handle | `CLI_DEID_INCOMPLETE` | `1` |
317
+ | `astm`, `mllp`, `ncpdp`: no adapter in that library | `CLI_NOT_IMPLEMENTED` | `69` |
318
+ | `dicom`: covered there, but its de-identified form is binary and this stdout is text | `CLI_FORMAT_UNSUPPORTED` | `65` |
319
+ | `@cosyte/deid` not installed (an optional dependency) | `CLI_PARSER_UNAVAILABLE` | `69` |
320
+
321
+ stderr carries the library's **own value-free manifest** (category, transform, the structural path,
322
+ count, disposition and its stable code) and the library's **own published label and version**: the CLI
323
+ asserts no de-identification standard of its own. An absent library is decided **before your input is
324
+ read**.
325
+
326
+ What the library's default policy does decides which of the first two rows you get, and the CLI
327
+ passes it no policy of its own:
328
+
329
+ - **Medical record, account and member numbers are removed**, not replaced by a surrogate: their
330
+ manifest lines read `removed DEID_CATEGORY_REMOVED`.
331
+ - **A visit number or an order number is blocked, so the whole run is refused.** An HL7 visit number
332
+ (`PV1-19`), and a placer or filler order number (`ORC-2`/`ORC-3`, `OBR-2`/`OBR-3`), fall to the
333
+ library's catch-all identifier category, which its default policy blocks rather than removes. Such a
334
+ message exits with `CLI_DEID_INCOMPLETE` (exit `1`) and nothing on stdout, and stderr names each
335
+ blocked locus with the library's `DEID_LOCUS_BLOCKED` code. Many real ADT messages carry `PV1-19`,
336
+ so check the exit code before you use `clean.hl7`.
337
+
338
+ The CLI still keys every run with a **per-invocation ephemeral key** and says so on stderr: any
339
+ identifier surrogate is consistent within one output and deliberately not stable across runs.
280
340
 
281
341
  ## `cosyte completion`
282
342
 
@@ -305,8 +365,7 @@ subprocess**, not a hosted endpoint. Register it in an MCP client's config:
305
365
  > **`--package` is required, and the shorter `["-y", "@cosyte/cli", "mcp"]` does not work**: it fails
306
366
  > with `could not determine executable to run`, for the `npx` executable-selection reason described
307
367
  > under [Run it](#run-it) above. This also needs a version that can be installed at all, so not
308
- > `0.0.1` or `0.0.2`. The `convert` tool reports `CLI_PARSER_UNAVAILABLE` from an npm install, because
309
- > the FHIR library is not on the registry.
368
+ > `0.0.1` or `0.0.2`.
310
369
 
311
370
  `cosyte mcp` and the standalone `cosyte-mcp` bin both start the stdio server. It exposes four tools
312
371
  (`parse`, `validate`, `inspect`, `convert`), each calling the same command the terminal runs, so the CLI
@@ -315,9 +374,15 @@ and the agent get identical results. The PHI posture is inherited and hardened:
315
374
  tool _error_ carries only value-free diagnostics (a stable code + position, never an input value). A
316
375
  parsed-but-invalid `validate` is a **successful** call reporting the verdict, not a tool error.
317
376
 
377
+ **Every tool publishes an `outputSchema`**, so a client validates a result against a declared contract
378
+ rather than pattern-matching prose. Each result carries `ok`, a `status` of `success` / `verdict` /
379
+ `failed`, the `exit` code, a stable `code` on a failure, and the tool's own payload under `data`; the
380
+ text content block carries the serialized JSON of that same structured result. See the docs for the
381
+ per-tool payload shapes.
382
+
318
383
  The MCP SDK (`@modelcontextprotocol/sdk`) is the CLI's only third-party runtime dependency; it is
319
- declared **optional** and loaded only on the MCP path, so a `cosyte parse` invocation never pulls it and
320
- the core works with the SDK absent. The server surface is importable via the `@cosyte/cli/mcp` subpath
384
+ declared **optional**, and the programmatic core (the `.` subpath) loads with the SDK absent. The server
385
+ surface is importable via the `@cosyte/cli/mcp` subpath
321
386
  (`createMcpServer`, `startStdioServer`, `dispatchTool`, `TOOL_DEFS`).
322
387
 
323
388
  ## Programmatic API