claude-memory-lint 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 ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-18
11
+
12
+ First public release.
13
+
14
+ ### Added
15
+
16
+ - `memory-lint` CLI with a dispatcher over six detectors, plus an `all` subcommand
17
+ that runs every detector in one pass.
18
+ - **`budget`** — closed size budget for the memory index: line count, byte count,
19
+ and a proximity warning before the reader's line limit is reached.
20
+ - **`frontmatter`** — every memory file must carry valid frontmatter with a
21
+ non-empty `description`, so it can be selected instead of sitting unread on disk.
22
+ - **`provenance`** — per-line provenance markers on claims that carry a date or a
23
+ number, with a baseline file so an existing archive can adopt the rule without a
24
+ flag on every pre-existing line.
25
+ - **`perishable`** — perishable-state tracking: claims about external systems that
26
+ were true when written and decay silently afterwards.
27
+ - **`pii`** — personal-data and secrets scan over the archive, with a category file
28
+ that can be replaced through config.
29
+ - **`honesty`** — suppression-instruction detector, judged by the effect of the
30
+ instruction rather than by its wording.
31
+ - Mention-is-not-use handling across the detectors: a marker, date, or pattern
32
+ inside a code span or backticks is read as an example, not as a real occurrence.
33
+ - Machine-readable output and distinct exit codes per outcome, for use as a CI gate.
34
+ - Config resolution from `memory-lint.config.json` and environment variables, over
35
+ built-in defaults.
36
+ - No runtime dependencies and no network calls. The tool reads the archive and
37
+ reports; it never edits it.
38
+
39
+ [Unreleased]: https://github.com/vgrosetti-maker/claude-memory-lint/compare/v0.1.0...HEAD
40
+ [0.1.0]: https://github.com/vgrosetti-maker/claude-memory-lint/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nord Leads
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,340 @@
1
+ # claude-memory-lint
2
+
3
+ [![CI](https://github.com/vgrosetti-maker/claude-memory-lint/actions/workflows/ci.yml/badge.svg)](https://github.com/vgrosetti-maker/claude-memory-lint/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/claude-memory-lint.svg)](https://www.npmjs.com/package/claude-memory-lint)
4
+
5
+ Structural checks for an agent memory archive: size budget, required frontmatter, per-line provenance, perishable state, personal data, and suppression instructions.
6
+
7
+ Six detectors, one CLI, machine-readable output, no network calls. It reads your memory files and reports. It never edits them.
8
+
9
+ ## The problem
10
+
11
+ An agent memory archive is append-only by nature. Every session that ends well adds a line, and nothing ever takes one out. Three failure modes follow from that, and none of them announce themselves.
12
+
13
+ **The index file grows past what the reader loads.** The memory index is read at every session boot. The host application loads up to a certain number of lines and warns about the rest. Newly written entries land at the end of the index, so they are the first to fall off the edge. The file still exists, the lines are still in it, and the agent simply never sees them. Nothing is broken, nothing errors, and the memory you wrote last week is silently out of context.
14
+
15
+ **Provenance collapses.** In the same paragraph you will find something a person said, a number that came out of a command that actually ran, and the model's own guess. After a week nobody can tell them apart, and the guess gets cited later as if it had been measured.
16
+
17
+ **Perishable state is recorded as if it were stable.** A handoff note says an external system is blocked, a migration is paused at step five, a spec lives in section two. All true the day it was written. Days later the block is gone, but the note still says it is there, and someone builds an expensive workaround around a restriction that no longer exists.
18
+
19
+ None of the three produces an error message, so they surface only when somebody acts on the wrong answer, usually months later.
20
+
21
+ ## Install
22
+
23
+ Requires Node 18 or newer. No dependencies.
24
+
25
+ ```
26
+ npx claude-memory-lint all
27
+ ```
28
+
29
+ Or install it in the project that holds your config and baseline:
30
+
31
+ ```
32
+ npm install --save-dev claude-memory-lint
33
+ npx memory-lint all
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```
39
+ memory-lint <command> [flags]
40
+ ```
41
+
42
+ | Command | What it checks |
43
+ | --- | --- |
44
+ | `budget` | closed line and byte budget for the memory index file |
45
+ | `frontmatter` | valid frontmatter with a non-empty `description` on every file |
46
+ | `provenance` | per-line `(stated)` / `(measured)` / `(inferred)` markers |
47
+ | `perishable` | stable residue vs. perishable state on state cards |
48
+ | `pii` | personal data and secrets that should never be recorded |
49
+ | `honesty` | suppression instructions, judged by effect |
50
+ | `all` | runs every detector and reports each verdict |
51
+
52
+ `--json` works on every command and prints the full verdict, including the failing lines, as JSON.
53
+
54
+ ```
55
+ memory-lint budget --json
56
+ memory-lint all --json
57
+ ```
58
+
59
+ Note on `all --json`: it prints one JSON document per detector, separated by a `=== name ===` header line. That is a stream of six documents, not a single JSON document, so parse it per detector or call the detectors individually.
60
+
61
+ ### Flags handled at the dispatcher
62
+
63
+ Two flags are read by `memory-lint` itself and work on every command:
64
+
65
+ | Flag | Effect |
66
+ | --- | --- |
67
+ | `--archive <dir>` | scan `<dir>` instead of the auto-detected archive. It sets `MEMORY_LINT_DIR` for the detector's own process, so the origin line prints `MEMORY_LINT_DIR` next to the path. |
68
+ | `--categories <file>` | `pii` only: use `<file>` instead of the shipped category list. It sets `MEMORY_LINT_PII_CATEGORIES`. |
69
+
70
+ ```
71
+ memory-lint pii --archive ./fixtures/archive --categories ./my-categories.json
72
+ memory-lint all --archive ./fixtures/archive
73
+ ```
74
+
75
+ `--archive` is the flag to use when trying the tool out: point it at a throwaway directory instead of running the first check against a real archive.
76
+
77
+ An unrecognized `--flag` is a hard error and exits `2`. It is never dropped silently, because a dropped flag means the detector goes on to scan whatever archive it finds by default, and a green result over the wrong directory reads exactly like a green result over the right one.
78
+
79
+ ### Exit codes
80
+
81
+ | Code | Meaning |
82
+ | --- | --- |
83
+ | `0` | checked, nothing failed |
84
+ | `1` | checked, something failed |
85
+ | `2` | **not checked**: archive missing, config unreadable, category list unreadable, baseline absent, or an unrecognized flag |
86
+
87
+ Exit `2` is deliberately distinct from `0`. A check that could not run is not a check that passed, and a gate that reports "green" when it never looked is worse than no gate. In CI, treat `2` as a failure.
88
+
89
+ ### First run: creating the provenance baseline
90
+
91
+ `provenance` refuses to run without a baseline, and exits `2` until one exists. Create it once:
92
+
93
+ ```
94
+ memory-lint provenance --genesis
95
+ ```
96
+
97
+ Genesis writes a content baseline: a per-file hash of every line that exists today, unfiltered. Those lines are exempt from judgment from then on. Only lines written after the baseline are judged. This is the alternative to a detector that debuts by flagging the entire archive, which leaves the gate permanently red and therefore meaningless. Commit the baseline file.
98
+
99
+ Two more flags on `provenance`:
100
+
101
+ - `--record-baseline` adds newly written lines to the baseline incrementally. It **rejects** any line that asserts a verifiable fact without a marker, so re-recording is not an amnesty: the rejected lines stay in the gate and the check stays red until they are marked.
102
+ - `--audit-baseline` checks that the baseline file is committed and unmodified in git. An uncommitted baseline is an exemption list that nobody reviewed.
103
+
104
+ ## Configuration
105
+
106
+ `memory-lint.config.json` in the current working directory, merged over the built-in defaults. Point `MEMORY_LINT_CONFIG` at another path to override the location.
107
+
108
+ ```json
109
+ {
110
+ "budget": {
111
+ "lines": 200,
112
+ "bytes": 20000,
113
+ "readerLineLimit": 200,
114
+ "proximityWarning": 190
115
+ },
116
+ "provenance": {
117
+ "baselineFile": "memory-lint.baseline.json",
118
+ "markers": ["stated", "measured", "inferred"]
119
+ },
120
+ "perishable": { "cutoffDate": null },
121
+ "pii": { "categoriesFile": null }
122
+ }
123
+ ```
124
+
125
+ - `budget.lines` / `budget.bytes` are the ceiling for the index file. The shipped numbers are round placeholders, not a measurement of anything. Run `memory-lint budget` once, read your actual size, and set the ceiling to that size. That is what makes the budget *closed*: new content has to merge into or replace existing content instead of only adding to it.
126
+ - `budget.readerLineLimit` is how many lines the host application actually loads at boot. It is configurable because it is the host's behavior, not this tool's, and it can change. It is checked separately from the budget: the budget bounds growth, this bounds visibility. A ceiling set above the reader's cutoff can stay green while the newest lines never reach context.
127
+ - `budget.proximityWarning` warns before the reader limit starts cutting, rather than after.
128
+ - `provenance.markers` sets the three marker words. They default to English; change them if your archive is written in another language.
129
+ - `perishable.cutoffDate` (`YYYY-MM-DD`) judges only files touched on or after that date. Set it to the date you adopt the convention so pre-existing files are not flagged retroactively. `null` judges everything.
130
+ - `pii.categoriesFile` points at your own category list. A custom file **replaces** the shipped list, it never merges with it, so a partial file silently drops the categories it omits.
131
+
132
+ ### Environment variables
133
+
134
+ | Variable | Effect |
135
+ | --- | --- |
136
+ | `MEMORY_LINT_DIR` | the archive directory to check |
137
+ | `MEMORY_LINT_CONFIG` | path to the config file |
138
+ | `MEMORY_LINT_BASELINE` | path to the provenance baseline |
139
+ | `MEMORY_LINT_PII_CATEGORIES` | path to the `pii` category list, replacing the shipped one |
140
+
141
+ `MEMORY_LINT_PII_CATEGORIES` wins over `pii.categoriesFile` in the config file, which in turn wins over the shipped list. `--categories <file>` is the same setting from the command line: it sets the variable for the run.
142
+
143
+ ### Where the archive is found
144
+
145
+ In order: `MEMORY_LINT_DIR` (which is what `--archive <dir>` sets), then `autoMemoryDirectory` in `~/.claude/settings.local.json` or `~/.claude/settings.json`, then a default derived from your home directory. Every command prints which of the three decided, next to the path. A path with no stated origin is how an audit ends up scanning the wrong folder and reporting a confident zero.
146
+
147
+ ## The six detectors
148
+
149
+ ### `budget`
150
+
151
+ Measures the index file in lines and bytes against the configured ceiling, and separately against the reader line limit.
152
+
153
+ Lines and bytes are a **proxy** for the token cost of loading the file at boot, never a conversion. A bytes-per-token ratio measured on English text underestimates other languages by a wide margin. If you want the real token number, measure it by ablation (compare two boots with and without the file), not by dividing bytes.
154
+
155
+ ### `frontmatter`
156
+
157
+ Every file needs valid frontmatter with a non-empty `description`. Without one, the file is invisible to whatever decides which memory to open: it is on disk and it is never selected.
158
+
159
+ Declared scope: it fails on missing frontmatter or a missing description. It does not judge whether the description is any good. That is a content call, not detectable by shape.
160
+
161
+ ### `provenance`
162
+
163
+ One marker per claim, three values:
164
+
165
+ - `(stated)` a person said so, this session or an earlier one
166
+ - `(measured)` it came out of a command that actually ran
167
+ - `(inferred)` the model's hypothesis, not verified
168
+
169
+ A marker is required only on a line that **asserts a verifiable fact**: a number or count, a date, a URL, or an id or hash. Headings, blank lines, pointers to another note, separators and blockquotes are exempt by design. Reading "every line carries a marker" literally would make the file unreadable and fail the gate on plain prose, which is not the problem this solves.
170
+
171
+ The cut is by **line**, not by file. Judging by file drags a whole old note into the gate the moment you append three lines to it, and an append-only archive gets appended to constantly.
172
+
173
+ ### `perishable`
174
+
175
+ The split it enforces:
176
+
177
+ - **Stable residue**: the area exists, the decision was made, the rule holds. Write it down, it ages well.
178
+ - **Perishable state**: "paused at step 5 of 9", "the block is active", "the spec is in section 2". This is a measurement of a moment. Let it expire, or if it has to stay on record, give it a **measurement date** and a **recheck route**, so it is perishable by construction.
179
+
180
+ Which files count as state cards comes from a declared signal, not from reading prose: `type: project` in the frontmatter, or an explicit `measuredOn:` / `recheck:` field. Judging the body by keyword ("paused", "live", "active") was considered and rejected, because it cannot tell an actual state claim from prose describing someone else's state, and it produces false positives in both directions.
181
+
182
+ ### `pii`
183
+
184
+ Scans for personal data and secrets against one category list, `lib/pii-categories.json`. The detector file declares no pattern of its own: a category that lives in two places becomes two categories that drift, and the one that drifts is always the one the gate reads.
185
+
186
+ Three gate levels:
187
+
188
+ | Gate | Behavior | Categories |
189
+ | --- | --- | --- |
190
+ | `block` | an occurrence exits `1` | `nationalId`, `bankAccount`, `health`, `credential` |
191
+ | `warn` | counted and reported, never exits `1` | `minor`, `specialCategoryOther`, `directIdentifier`, `onlineIdentifier`, `criminalRecord` |
192
+ | `doctrine` | not detectable by form, carries no pattern, never enters code | `falseAnonymity` |
193
+
194
+ #### What the gate measures
195
+
196
+ Every category carries a `gateRationale`. It prints on every run, under that category's count line, and it sits in `--json` at `summary.byCategory.<id>.gateRationale`. Read it before you read the gate, because the gate ranks how far the pattern can be trusted. **A `warn` category is not a less sensitive category.**
197
+
198
+ `health` is `block` and `minor` is `warn`. Both are special-category data under GDPR Art. 9(1), carrying the same legal exposure. What differs is the vocabulary. The clinical terms behind `health` have almost no other meaning in an engineering note. The words that signal a minor collide constantly with ordinary prose, where a "child" is a DOM node or a process. `specialCategoryOther` is `warn` for the same reason, and `criminalRecord` is `warn` even though Art. 10 carries its own strict regime, because "requires a background check" is a policy sentence and a real disclosure written with the same words.
199
+
200
+ So a clean `block` line with warnings under it is not a clean archive. It is an archive where the high-precision patterns found nothing and the noisier ones found something a human still has to read.
201
+
202
+ #### Why a `doctrine` category prints no count
203
+
204
+ `falseAnonymity` is never evaluated. The report prints `not machine-checkable — needs a human pass` for it, and `--json` carries `machineCheckable: false` on that category and `true` on the other nine. It used to print `0 occurrence(s)`, which is indistinguishable from a check that ran and came back clean.
205
+
206
+ #### The report state
207
+
208
+ `pii` reports one of three states, at the top level of `--json`:
209
+
210
+ | `state` | Meaning | Exit |
211
+ | --- | --- | --- |
212
+ | `ok` | no occurrence in any machine-checkable category | `0` |
213
+ | `ok_with_warnings` | nothing in a `block` category, at least one `warn` occurrence | `0` |
214
+ | `blocked` | at least one occurrence in a `block` category | `1` |
215
+
216
+ `ok_with_warnings` exists because the run used to report `ok` with warnings sitting in the same summary. Exit codes did not change: only a `block` occurrence exits `1`, so a CI job keyed on the exit code behaves as it did. A job that wants warnings to be visible has to read `state`, or `summary.warning`.
217
+
218
+ Ten categories, of which three are `warn` categories that are easy to overlook:
219
+
220
+ - `directIdentifier` — an e-mail address or a phone number. Carve-out: a published business contact.
221
+ - `onlineIdentifier` — an IPv4 address, which is personal data when it can be tied to a person. Carve-out: a CIDR range or reserved block used to document infrastructure. A cookie or device ID is deliberately not matched: there is no shared format across vendors to match on.
222
+ - `criminalRecord` — an offence, conviction or related proceeding. Carve-out: stating a policy requirement rather than a person's record.
223
+
224
+ `credential` matches shapes that exist for nothing but the secret itself: a token or key format, a PEM private-key header (`-----BEGIN ... PRIVATE KEY-----`), and a password written out in plain text (`password:` or `password=` followed by a value). Like `nationalId` and `bankAccount`, it is matched against the raw line, so a key header or a password pasted inside a fenced log block still counts.
225
+
226
+ `specialCategoryOther` also carries a **genetic data** pattern (`genetic test / marker / screening / data`, `DNA test / sample / marker`) alongside the rest of its closed vocabulary. Art. 9 names genetic data next to biometric data, and only the second one had a term.
227
+
228
+ The detector **refuses** a category that declares `doctrine` and still carries a pattern. That would be doctrine becoming a gate through the back door.
229
+
230
+ Two things worth knowing before you read a report:
231
+
232
+ - A long numeric platform id can match the phone-number pattern and be counted as a `directIdentifier` warning. It is noise, not a block, and it is the price of a phone pattern loose enough to catch international formats.
233
+ - The shipped category list carries a Luhn-valid test card number and a canonical example IBAN as fabricated examples. They are public test values, not anyone's data, but a third-party secret scanner may flag them.
234
+
235
+ Exemptions are scoped two ways: an exemption applies to the pattern ids named in its `appliesTo` (all of the category's patterns when `appliesTo` is absent), and it has to appear in the **same clause** as the match, where clauses split on `, ; : . ! ?`. "Card ending in 6467" is one clause and stays exempt; a carve-out phrase in a different clause of the same line does not excuse a match somewhere else on that line.
236
+
237
+ An exemption can also be voided. `nullifiedBy` on an exemption lists the pattern ids that cancel it: if any of them matches in the same clause, the exemption does not apply, even where its own wording is present. The case it exists for is a note that names a category in order to teach it. "PII note: the contact has cancer" and "Never store this kind of thing: the contact is pregnant" both name the rule and record the thing the rule forbids, in one clause. Naming a category is not a carve-out for a real occurrence of it, so the clinical patterns are listed in `nullifiedBy` on that exemption and the occurrence is reported.
238
+
239
+ `health` ships with a closed vocabulary and a declared low coverage, stated in the category record itself. It is the pattern most likely to miss a real case, because prose describing a condition in someone's own words will not match a closed vocabulary. Declaring that gap is the honest response; widening the regex would only hide it behind false positives.
240
+
241
+ ### `honesty`
242
+
243
+ No memory in the archive should contain an instruction that, if followed, would make a real problem stop being raised. Someone tells their agent it can disagree and push back, then on a bad afternoon writes a memory saying to stop questioning their numbers. From then on, every future session quietly obeys the second instruction, and nobody notices until an error that should have been flagged was not.
244
+
245
+ **The criterion is the effect, not the wording.** An instruction is still caught when it arrives softened ("only flag it if it's really bad"), scoped to one topic, justified by a deadline, or dressed up as a preference about format, tone or efficiency. So the detector never looks for one forbidden sentence. It looks for the structure *suppressor plus honesty act*, across six families: `negated_act`, `negated_signal`, `silencing`, `noun_cut`, `forced_agreement`, `severity_threshold`.
246
+
247
+ **What passes:** a legitimate preference about *form*. Length ("keep it short"), tone ("blunt, no pleasantries"), directness ("skip the preamble"), scope of an edit ("only change the part I asked about"). None of those stop a problem from being raised, and none of them trip this detector.
248
+
249
+ A finding here is not fixed by writing a softer version of the same memory. Needing a softer version is itself the signal that the line should not be on record. Editing a memory is the owner's call, not the tool's.
250
+
251
+ ## Mention is not use
252
+
253
+ Shared by `provenance`, `perishable`, `pii` and `honesty`: text inside a backtick span or a fenced code block is quotation and is out of judgment, both ways. A note explaining one of these detectors has to be able to show the phrase it catches without tripping the gate on itself. A marker quoted inside backticks equally does not count as a real marker.
254
+
255
+ Three `pii` categories opt out of that rule: `nationalId`, `bankAccount` and `credential` are matched against the raw line, code spans included. For a category whose match *is* the leak, a token or an ID pasted inside a fenced log block is still a leaked token or ID. The prose categories a note might cite while explaining itself keep the exemption.
256
+
257
+ Quotation marks are deliberately **not** exempted the same way. A real instruction fits inside quotes just as well as a quoted one does, so exempting them would move the false positive rather than remove it.
258
+
259
+ ## Design choices
260
+
261
+ Three that are easy to miss from the command list:
262
+
263
+ 1. **Provenance is per line, with a content baseline.** Not per file, not per session, and with no amnesty path: re-recording the baseline rejects unmarked lines instead of absorbing them.
264
+ 2. **Perishable and stable are different kinds of memory**, and only the perishable kind is required to carry an expiry route. The boundary is a declared frontmatter signal, never a keyword scan of the prose.
265
+ 3. **Personal data is a gate on the memory archive itself**, with the categories in data rather than in code, and with the low-recall category saying so in its own record instead of implying coverage it does not have.
266
+
267
+ ## What this tool does not see
268
+
269
+ Read this section before trusting a green result.
270
+
271
+ - **It never edits your memory files.** Every detector reports. Remediation is a human decision.
272
+ - **`memory-lint budget` reads `MEMORY_LINT_DIR`, or the current directory if that is unset.** It does not use the archive auto-discovery the other five detectors use. If `budget` reports the index file as absent, set `MEMORY_LINT_DIR`.
273
+ - **Lines and bytes are not tokens.** See `budget` above.
274
+ - **`provenance` only judges lines written after the baseline.** Everything older is reported as declared backlog and is never judged. The count is printed so the gap is visible rather than implied.
275
+ - **`provenance` judges whether a claim carries a marker, not whether the marker is true.** Writing `(measured)` on a number you invented passes.
276
+ - **`perishable` only looks at files that declare themselves state cards.** Perishable state written in a file with no such signal is not seen.
277
+ - **The prose-based `pii` categories (`health`, `minor`, `specialCategoryOther`, `criminalRecord`) use English vocabulary only.** An archive kept in another language will have lower recall on those until someone adds that language. The format-based categories (`nationalId`, `bankAccount`, `credential`, `onlineIdentifier`, and the e-mail pattern of `directIdentifier`) are language-independent. This limit is stated in `_meta.languageScope` of the category list, and repeated in `_meta.coverageScope`.
278
+ - **`pii` declares a list of things it does not scan for at all**, printed under `NOT scanned by this list` on every run and held in `_meta.coverageScope`: a person's **name** in free-form prose, a full **postal address** in prose, **precise geolocation** written as prose rather than coordinates, a **cookie or device identifier**, and **philosophical or political conviction** beyond the closed vocabulary already in `specialCategoryOther`. Names and addresses have no fixed shape to match on, and catching them needs named-entity recognition, which this list does not attempt. A clean `pii` run says nothing about any of them.
279
+ - **`pii` never evaluates `falseAnonymity`.** It is a `doctrine` category: re-identification by combination is a judgment about how small a population a set of attributes narrows down to, and no regex approximates that. The report says `not machine-checkable` for it on every run instead of a count, so a green `pii` run says nothing at all about it.
280
+ - **A `warn` gate ranks the pattern, not the data.** `minor`, `specialCategoryOther` and `criminalRecord` carry the same legal exposure as the categories that block; their patterns are just noisier. Each category's `gateRationale`, printed on every run, states the reason for its own gate.
281
+ - **`pii` does not decide legal basis, retention, or whether you are allowed to hold a piece of data.** It reports that something matching a category is sitting in a long-lived file. What follows from that is your call and your jurisdiction's.
282
+ - **`honesty` catches a structure, not an intention.** A suppression phrased outside its six families will pass, and a sentence that resembles the structure while meaning something else can be flagged. Treat a finding as a prompt to go read the line.
283
+ - **Nothing here judges whether a memory is *worth keeping*.** Every check is structural. A well-formed, fully marked, budget-compliant archive of useless notes passes cleanly.
284
+
285
+ ## Example
286
+
287
+ Synthetic archive, two files:
288
+
289
+ ```
290
+ $ memory-lint all
291
+ === budget ===
292
+ archive: /tmp/archive/MEMORY.md
293
+ lines: 3 / budget 200 bytes: 54 / budget 20000
294
+
295
+ BUDGET: within budget
296
+
297
+ === perishable ===
298
+ total: 1 state cards: 1 stable residue (out of scope): 0
299
+ in cutoff: ok 0 unmarked 1 (missing: 2)
300
+
301
+ PERISHABLE: 1 failed.
302
+ example-card.md [measurement_date+recheck_route] (metadata.type: project)
303
+ ```
304
+
305
+ Every number above comes from a two-file archive fabricated for this example, and the budget figures in it are the shipped placeholder defaults. They are not a measurement of any real archive.
306
+
307
+ A `pii` run over a second fabricated archive, trimmed to the lines that show the per-category output:
308
+
309
+ ```
310
+ $ memory-lint pii --archive ./fixtures/archive
311
+ pieces judged: 2 occurrences: 1 (block 0 / warn 1)
312
+ ...
313
+ onlineIdentifier [warn]: 1 occurrence(s), 0 exempt(ed) by carve-out
314
+ gate: warn: an IPv4 address is only personal data when it links back to a
315
+ person, and this list has no way to tell [...]
316
+ falseAnonymity [doctrine]: not machine-checkable — needs a human pass
317
+ gate: doctrine, not warn: re-identification by combination has no fixed
318
+ shape at all [...]
319
+
320
+ note-one.md (body):1 [onlineIdentifier/ipv4]
321
+ ... The staging box answered from 203.0.113.9 (measured). ...
322
+ ```
323
+
324
+ The same run with `--json` reports `"state": "ok_with_warnings"` and exits `0`. The two `gate:` lines are each one line in the real output, wrapped and cut here to fit. The archive is fabricated and the address is from the documentation range reserved for examples.
325
+
326
+ ## In CI
327
+
328
+ ```yaml
329
+ - run: npx memory-lint all
330
+ ```
331
+
332
+ `all` exits with the worst code of the six, so `2` (something could not be checked) fails the job the same as `1`. If you run only `provenance`, remember the baseline has to be committed for `--audit-baseline` to mean anything.
333
+
334
+ ## Contributing
335
+
336
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
337
+
338
+ ## License
339
+
340
+ MIT
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // Unified CLI: dispatches to each detector's own CLI block.
4
+ //
5
+ // Usage:
6
+ // memory-lint budget [--json]
7
+ // memory-lint frontmatter [--json]
8
+ // memory-lint provenance [--json | --record-baseline | --genesis | --audit-baseline]
9
+ // memory-lint perishable [--json]
10
+ // memory-lint pii [--json]
11
+ // memory-lint honesty [--json]
12
+ // memory-lint all [--json]
13
+ //
14
+ // Each subcommand's exit code is the standard one for that detector: 0 ok,
15
+ // 1 failed, 2 not checked (archive/config missing). `all` exits non-zero if
16
+ // any sub-check exits non-zero.
17
+ const path = require('path');
18
+ const { spawnSync } = require('child_process');
19
+
20
+ const HELP = `memory-lint <command> [flags]
21
+
22
+ Commands:
23
+ budget closed line/byte budget for MEMORY.md
24
+ frontmatter required frontmatter + description on every file
25
+ provenance per-line (stated)/(measured)/(inferred) marker check
26
+ perishable stable residue vs. perishable state on "project" cards
27
+ pii personal data / secrets that should never be recorded
28
+ honesty suppression instructions, judged by effect
29
+ all run every detector, report each verdict
30
+
31
+ Flags are forwarded to the detector:
32
+ --json full verdict as JSON, on any command
33
+ --genesis provenance only: write the initial line baseline
34
+ --record-baseline provenance only: add new lines, reject unmarked ones
35
+ --audit-baseline provenance only: check the baseline is committed in git
36
+
37
+ Flags handled HERE, at the dispatcher, on every command:
38
+ --archive <dir> scan <dir> instead of the auto-detected archive
39
+ (sets MEMORY_LINT_DIR for the detector's own process)
40
+ --categories <file> pii only: use <file> instead of the shipped category
41
+ list (sets MEMORY_LINT_PII_CATEGORIES)
42
+
43
+ An unrecognized "--flag" is a hard error (exit 2), never a silent no-op: a
44
+ misspelled or unsupported flag used to be dropped on the floor and the
45
+ detector would go on to scan whatever archive it finds by default — a green
46
+ result over the WRONG directory reads exactly like a green result over the
47
+ right one.
48
+
49
+ Exit codes: 0 nothing failed | 1 something failed | 2 NOT CHECKED (archive,
50
+ config, baseline missing, or a bad flag). "all" exits with the worst code of
51
+ the six. See README.md.
52
+ `;
53
+
54
+ const DETECTORS = {
55
+ budget: path.join(__dirname, '..', 'lib', 'detectors', 'budget.js'),
56
+ frontmatter: path.join(__dirname, '..', 'lib', 'detectors', 'frontmatter.js'),
57
+ provenance: path.join(__dirname, '..', 'lib', 'detectors', 'provenance.js'),
58
+ perishable: path.join(__dirname, '..', 'lib', 'detectors', 'perishable.js'),
59
+ pii: path.join(__dirname, '..', 'lib', 'detectors', 'pii.js'),
60
+ honesty: path.join(__dirname, '..', 'lib', 'detectors', 'honesty.js'),
61
+ };
62
+
63
+ // Flags every detector's own CLI block already parses for itself — these
64
+ // pass through untouched. `--archive`/`--categories` are NOT in this set:
65
+ // they are consumed here and turned into env vars instead (see `parseArgs`).
66
+ const PASSTHROUGH_FLAGS = new Set(['--json', '--genesis', '--record-baseline', '--audit-baseline']);
67
+
68
+ // Splits `rest` into: env overrides this dispatcher applies, flags to forward
69
+ // to the detector's own process unchanged, and (if anything doesn't parse) an
70
+ // error message. Never returns a partially-applied result silently — either
71
+ // everything parsed, or `error` is set and nothing runs.
72
+ function parseArgs(rest) {
73
+ const forward = [];
74
+ let archive = null;
75
+ let categoriesFile = null;
76
+ for (let i = 0; i < rest.length; i++) {
77
+ const a = rest[i];
78
+ if (a === '--archive') {
79
+ archive = rest[++i];
80
+ if (archive === undefined) return { error: '--archive requires a directory argument' };
81
+ continue;
82
+ }
83
+ if (a === '--categories') {
84
+ categoriesFile = rest[++i];
85
+ if (categoriesFile === undefined) return { error: '--categories requires a file argument' };
86
+ continue;
87
+ }
88
+ if (a.startsWith('--')) {
89
+ if (!PASSTHROUGH_FLAGS.has(a)) return { error: `unknown flag "${a}"` };
90
+ forward.push(a);
91
+ continue;
92
+ }
93
+ return { error: `unexpected argument "${a}" (flags start with --)` };
94
+ }
95
+ return { forward, archive, categoriesFile };
96
+ }
97
+
98
+ function run(name, extraArgs) {
99
+ const parsed = parseArgs(extraArgs);
100
+ if (parsed.error) {
101
+ console.error(`memory-lint: ${parsed.error}\n`);
102
+ console.log(HELP);
103
+ return 2;
104
+ }
105
+ const env = Object.assign({}, process.env);
106
+ if (parsed.archive) env.MEMORY_LINT_DIR = parsed.archive;
107
+ if (parsed.categoriesFile) env.MEMORY_LINT_PII_CATEGORIES = parsed.categoriesFile;
108
+ const args = [DETECTORS[name], ...parsed.forward];
109
+ const r = spawnSync(process.execPath, args, { stdio: 'inherit', env });
110
+ return r.status === null ? 2 : r.status;
111
+ }
112
+
113
+ function main() {
114
+ const [cmd, ...rest] = process.argv.slice(2);
115
+ if (!cmd || cmd === '--help' || cmd === '-h') {
116
+ console.log(HELP);
117
+ process.exit(cmd ? 0 : 2);
118
+ }
119
+ if (cmd === 'all') {
120
+ let worst = 0;
121
+ for (const name of Object.keys(DETECTORS)) {
122
+ console.log(`\n=== ${name} ===`);
123
+ const code = run(name, rest);
124
+ worst = Math.max(worst, code);
125
+ }
126
+ process.exit(worst);
127
+ }
128
+ if (!DETECTORS[cmd]) {
129
+ console.error(`memory-lint: unknown command "${cmd}"\n`);
130
+ console.log(HELP);
131
+ process.exit(2);
132
+ }
133
+ process.exit(run(cmd, rest));
134
+ }
135
+
136
+ main();