etymd 0.2.2 → 0.3.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/README.md +145 -37
  3. package/dist/{approve-VC4V3OPW.js → approve-3B2UL7LG.js} +6 -4
  4. package/dist/audit-4YB3K62W.js +12 -0
  5. package/dist/{brief-TZ45MNHD.js → brief-6EEXGQUW.js} +6 -4
  6. package/dist/chunk-CRJHIXJH.js +5 -0
  7. package/dist/{chunk-LVLZLCND.js → chunk-DKC7MLWG.js} +1 -1
  8. package/dist/chunk-EF6BPBG6.js +22 -0
  9. package/dist/{chunk-BH37LELV.js → chunk-HOLFW5QY.js} +8 -5
  10. package/dist/chunk-LT67FU2Y.js +59 -0
  11. package/dist/{chunk-IW6K5HLV.js → chunk-MEJ4MBVL.js} +2 -2
  12. package/dist/{chunk-EUIHYD6F.js → chunk-MMPV67FW.js} +2 -2
  13. package/dist/{chunk-ANCCGYSH.js → chunk-NMZ3RHWW.js} +31 -59
  14. package/dist/{chunk-FZSIOCOZ.js → chunk-R74SJ7HS.js} +1 -1
  15. package/dist/{chunk-LBRNZZZF.js → chunk-TADKOW6P.js} +1 -99
  16. package/dist/{chunk-KMAJIQHC.js → chunk-V6NZEIHF.js} +7 -5
  17. package/dist/{chunk-MWIK6ML3.js → chunk-VFUYQLW7.js} +141 -25
  18. package/dist/{chunk-KBF3SW3N.js → chunk-WWBF4Y7K.js} +1 -1
  19. package/dist/chunk-YLNQQZ5F.js +102 -0
  20. package/dist/cli.js +50 -16
  21. package/dist/config-MX7OYI7F.js +4 -0
  22. package/dist/{context-GYCLR3TG.js → context-4DGXO7JR.js} +5 -3
  23. package/dist/doctor-T76ZNL4S.js +19 -0
  24. package/dist/{fleet-N3G5ZIGR.js → fleet-WOPKM2Z3.js} +248 -10
  25. package/dist/gates-WIGDS5VF.js +262 -0
  26. package/dist/generate-I75IEXXF.js +6 -0
  27. package/dist/index.d.ts +122 -33
  28. package/dist/index.js +737 -257
  29. package/dist/{init-4CQOLFQG.js → init-NSYVVMF2.js} +9 -5
  30. package/dist/ledger-TMIV45AA.js +5 -0
  31. package/dist/scan-BAD2UJAL.js +5 -0
  32. package/dist/{scan-CYOOGJFG.js → scan-COEPZJBO.js} +6 -4
  33. package/dist/screen-U7JMWZPS.js +134 -0
  34. package/package.json +2 -2
  35. package/dist/audit-LZJRJS5V.js +0 -9
  36. package/dist/doctor-6JDAHGDT.js +0 -16
  37. package/dist/gates-G5BCMRRV.js +0 -71
  38. package/dist/ledger-RHQJH5EA.js +0 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # etymd
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Regeneration keeps what a repo already had, and a fleet can declare a repo has no gates.
8
+
9
+ **Preservation moved into the generator.** `etymd gates` read the existing hook and kept a test
10
+ step it already ran; every other caller of `planWorkflow` did not — including the fleet gate-drift
11
+ check, which therefore compared each repo against a hook missing checks the repo really runs.
12
+ That reported permanent false drift, and anyone acting on it would have silently lost a check. A
13
+ guarantee that holds only on the path someone remembered to wire it into is not a guarantee.
14
+
15
+ **`gates: "none"` in a fleet manifest** declares that generated gates are deliberately absent — a
16
+ prose repo with nothing mechanically checkable, or one that gates itself by hand. Gate drift sits
17
+ among the wall checks, which are deliberately not ledger-quietable because a leak or a partition
18
+ breach has no honest resolution except a fix; that reasoning does not transfer to a settled
19
+ choice, and an un-silenceable finding for a decision already made is how a report teaches people
20
+ to stop reading it. The absence is disclosed rather than hidden, and an _undeclared_ absence still
21
+ reports — silence has to be earned by declaring it.
22
+
23
+ **A hand-written `.etymd/config.json` no longer crashes the generator.** Setting only
24
+ `gates.failOn` threw: the type declares `commands` as required, describing the shape etymd writes
25
+ rather than the shape a user writes.
26
+
27
+ **The publish gate points at the committed script.** `prepublishOnly` resolving a bare
28
+ `artifact-check` from `PATH` meant the door guarding what actually ships was silently absent for
29
+ every clone but one.
30
+
31
+ ## 0.3.0
32
+
33
+ ### Minor Changes
34
+
35
+ - Content screening, a registration gate, and gates that can be regenerated without loss.
36
+
37
+ **`etymd screen` — a content screen with no opinions of its own.** Four scopes for the four ways
38
+ content leaves a repository: `--staged` (a commit), `--message` (the message, which the staged
39
+ scan cannot see), `--tree` (everything tracked), and `--dir` (an unpacked build artifact). The
40
+ last exists because the others share a blind spot — they answer "what is in the repository?",
41
+ while `npm` and `vsce` ignore `.gitignore`, so a local file can ship to users while every
42
+ git-scoped check passes forever.
43
+
44
+ It ships **no patterns and never will**: the strings worth screening for are themselves the
45
+ sensitive material. You supply a pattern file; without one the command is inert and says so,
46
+ rather than reporting clean for a check it did not run. A repository naming itself is exempt, and
47
+ a repo-level allow file covers lines that cannot carry an inline marker — a scanner's own source
48
+ necessarily contains the patterns it screens for.
49
+
50
+ **`etymd gates` generates all four doors**, including a `commit-msg` hook and a publish-time
51
+ screen wired to the key the project's publish route actually runs (`vsce` ignores
52
+ `prepublishOnly`). Generated hooks resolve the screener at run time and no-op when it is absent,
53
+ so the same file is safe to commit to a public repository.
54
+
55
+ **Your own checks live beside the generated ones.** Each hook calls `.githooks/<hook>.local` if it
56
+ exists — a file etymd never reads, writes, or regenerates. Regeneration no longer forces a choice
57
+ between accepting the pack and keeping your own guards, and it will not drop a test step an
58
+ existing hook already ran.
59
+
60
+ **Setup is one keystroke.** `gates` shows a plan with every derivation stated and asks once;
61
+ `customize` reaches each choice. Answers record to `.etymd/config.json`, where
62
+ `gates._why.<field>` can carry the reason a value is what it is — dropped automatically when the
63
+ value it explains changes, because a stale reason misleads exactly where it meant to inform.
64
+
65
+ **`etymd fleet add`** registers a project, prompting for what no scan can derive and refusing to
66
+ write an incomplete entry. Fleet manifests gain a mandatory `trust` level on non-corp entries —
67
+ absence is a finding, never a silent default — a manifest-level `orientation.root` replacing
68
+ per-entry links, and a gate-drift check that reports a repository missing a gate its siblings
69
+ install.
70
+
3
71
  ## 0.2.2
4
72
 
5
73
  ### Patch Changes
@@ -51,7 +119,7 @@
51
119
 
52
120
  ### Minor Changes
53
121
 
54
- - a58d3bd: Fleet mode — the truth guard across your repositories (design record `docs/design/004-fleet-truth-guard.md`; registry + fleet `--json` schemas EXPERIMENTAL through 0.2.x).
122
+ - a58d3bd: Fleet mode — the truth guard across your repositories (design record `docs/decisions/004-fleet-truth-guard.md`; registry + fleet `--json` schemas EXPERIMENTAL through 0.2.x).
55
123
 
56
124
  - New `state-freshness` truth lens: state/decisions artifacts dated by git committer dates only (never mtime); staleness is relative, so a dormant repo's old state is current; state char budget against the ~10k session-hook truncation; marker-gated decisions format checks (`Scope:`, duplicate/out-of-order `D-NNN` ids, past `Revisit:` dates as due review debt); ADR conventions (`docs/adr/`, `docs/decisions/`, `NNNN-*.md`) recognized natively.
57
125
  - New `etymd fleet` command family. The sweep runs a read-only audit per registered repo (`--manifest` required unless the cwd holds `registry.json` — no env var, no global pointer) and renders one line per project with a delta against `last.fleet.json`; detail only for new or risk findings. `fleet check` validates the manifest pair alone (dangling mappings, duplicate names, privacy leaks, machine paths). `fleet dismiss`/`fleet accept` resolve a project's finding from any cwd.
@@ -66,7 +134,7 @@ First public release.
66
134
 
67
135
  The truth guard for agent instruction files — **keep your agent instructions true**. (Formerly
68
136
  prototyped as "clothaid", a broader workflow installer; the pivot and its state-of-the-field
69
- rationale are recorded in `docs/design/003-truth-guard-pivot.md`.)
137
+ rationale are recorded in `docs/decisions/003-truth-guard-pivot.md`.)
70
138
 
71
139
  - `etymd audit` — verify every instruction claim against the actual repo, through three lenses:
72
140
  - **instruction-truth**: command claims vs `package.json` scripts, path claims vs the tree,
package/README.md CHANGED
@@ -1,7 +1,13 @@
1
- # etymd
1
+ # Etymd
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/etymd.svg)](https://www.npmjs.com/package/etymd)
4
- [![CI](https://img.shields.io/github/actions/workflow/status/triartleet/etymd/ci.yml?branch=main&label=CI)](https://github.com/triartleet/etymd/actions/workflows/ci.yml)
3
+ <div align="center">
4
+ <img src="media/etymd-logo.png" width="520" alt="Etymd — a papyrus of written instructions, each line checked against the repository it describes">
5
+ <p>
6
+ <a href="https://www.npmjs.com/package/etymd"><img src="https://img.shields.io/npm/v/etymd.svg?label=npm&color=cb3837" alt="npm version"></a>
7
+ <a href="https://github.com/triartleet/etymd/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/triartleet/etymd/ci.yml?branch=main&label=CI" alt="CI"></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
9
+ </p>
10
+ </div>
5
11
 
6
12
  **Keep your agent instructions true.**
7
13
 
@@ -9,7 +15,7 @@ You wrote rules for your AI months ago. Since then a script got renamed, a folde
9
15
  changed — and the AI still trusts every word. The file never complains when it goes stale; it just
10
16
  keeps instructing, confidently, and you live with the results.
11
17
 
12
- etymd reads those instruction files and checks every claim in them against your actual project.
18
+ Etymd reads those instruction files and checks every claim in them against your actual project.
13
19
 
14
20
  One command, run in your project's folder (needs Node ≥ 18.17, nothing else): `npx etymd audit`.
15
21
  Here it is on a small demo project whose AGENTS.md still tells the AI to run `npm run start` and
@@ -53,7 +59,7 @@ _From Greek **étymon** — a word's true, original sense (→ etymology) — cl
53
59
  - **Truth is a property over time, not a point in time.** Instruction files are load-bearing now —
54
60
  coding agents (Claude Code, Codex, Cursor, Copilot, Gemini, …) read `AGENTS.md` natively, and a
55
61
  stale claim doesn't error, it silently misleads every session. Linters for these files check a
56
- moment; etymd measures _drift_ against a committed _baseline_ and remembers findings in a
62
+ moment; Etymd measures _drift_ against a committed _baseline_ and remembers findings in a
57
63
  _ledger_, so fixed things stay fixed and a returning problem is named a _regression_ (all four
58
64
  words defined just below). It runs when you invoke it — or when a hook or CI job you wire up
59
65
  does.
@@ -64,7 +70,7 @@ _From Greek **étymon** — a word's true, original sense (→ etymology) — cl
64
70
  the checks filter aggressively — and every class of claim they skip is counted and disclosed,
65
71
  never silently dropped.
66
72
 
67
- ## The words etymd uses
73
+ ## The words Etymd uses
68
74
 
69
75
  | The docs say | It means |
70
76
  | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -84,7 +90,7 @@ _From Greek **étymon** — a word's true, original sense (→ etymology) — cl
84
90
 
85
91
  - **"It missed an obvious stale command."** Without `node_modules` installed, command claims are
86
92
  skipped — and the skip is disclosed in the report. A command might resolve to an installed
87
- binary, and etymd would rather say "couldn't check" than accuse an honest file. Install
93
+ binary, and Etymd would rather say "couldn't check" than accuse an honest file. Install
88
94
  dependencies and run again.
89
95
  - **`etymd audit` works without `etymd init`.** You only lose drift-over-time measurement — with
90
96
  no committed baseline, there is nothing to measure drift against. Everything else runs.
@@ -147,27 +153,29 @@ personal and employer repos. See [the fleet manifest](#the-fleet-manifest-experi
147
153
 
148
154
  ## Commands
149
155
 
150
- | Command | What it does |
151
- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
- | `etymd audit` | Verify every claim; ranked findings (risk → gap → polish) + ledger diff. `--lens`, `--truth`, `--json`, `--no-ledger`, `--fail-on <tier>`. |
153
- | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**. Never overwrites. |
154
- | `etymd doctor` | Alias for `audit --truth`. |
155
- | `etymd context` | The economy view: per-file always-loaded footprint + extraction candidates. |
156
- | `etymd gates` | Install local git-hook gates (pre-commit / pre-push) built from your own check scripts. |
157
- | `etymd scan` | The deterministic reckoning behind everything. `--json`. |
158
- | `etymd brief` | A grounded briefing your in-repo agent completes to author the semantic layer. |
159
- | `etymd approve` | Refresh the committed baseline non-interactively after intentional structural changes. |
160
- | `etymd ledger` | The findings memory: every tracked finding with status and history. |
161
- | `etymd dismiss` | `dismiss <id> --reason <text>` — a dismissed finding never resurfaces without regressing. |
162
- | `etymd accept` | `accept <id>` — record a finding as accepted reality; visible in the ledger, out of the report. |
163
- | `etymd fleet` | Sweep every project in a fleet manifest: read-only per-repo audits + manifest/wall checks. `--manifest`, `--only`, `--profile`, `--truth`, `--persist-ledgers`, `--json`, `--fail-on`. |
164
- | `etymd fleet check` | Validate the manifest pair alone (no lenses): dangling mappings, duplicate names, privacy leaks, machine paths. Non-zero exit on any finding. |
165
- | `etymd fleet dismiss` / `accept` | `<name> <id>` — resolve a project's finding from any cwd; corp findings persist beside the manifest, never in the corp worktree. |
156
+ | Command | What it does |
157
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `etymd audit` | Verify every claim; ranked findings (risk → gap → polish) + ledger diff. `--lens`, `--truth`, `--json`, `--no-ledger`, `--fail-on <tier>`. |
159
+ | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**. Never overwrites. |
160
+ | `etymd doctor` | Alias for `audit --truth`. |
161
+ | `etymd context` | The economy view: per-file always-loaded footprint + extraction candidates. |
162
+ | `etymd gates` | Install local git-hook gates (pre-commit / commit-msg / pre-push, plus a publish screen where something ships) built from your own check scripts. |
163
+ | `etymd screen` | Content screen: find text that must never be published. Four scopes — `--staged`, `--message`, `--tree`, `--dir`. Bring your own patterns; etymd ships none. |
164
+ | `etymd scan` | The deterministic reckoning behind everything. `--json`. |
165
+ | `etymd brief` | A grounded briefing your in-repo agent completes to author the semantic layer. |
166
+ | `etymd approve` | Refresh the committed baseline non-interactively after intentional structural changes. |
167
+ | `etymd ledger` | The findings memory: every tracked finding with status and history. |
168
+ | `etymd dismiss` | `dismiss <id> --reason <text>` — a dismissed finding never resurfaces without regressing. |
169
+ | `etymd accept` | `accept <id>` — record a finding as accepted reality; visible in the ledger, out of the report. |
170
+ | `etymd fleet` | Sweep every project in a fleet manifest: read-only per-repo audits + manifest/wall checks. `--manifest`, `--only`, `--profile`, `--truth`, `--persist-ledgers`, `--json`, `--fail-on`. |
171
+ | `etymd fleet check` | Validate the manifest pair alone (no lenses): dangling mappings, duplicate names, privacy leaks, undeclared trust, machine paths. Non-zero exit on any finding. |
172
+ | `etymd fleet add` | `add <dir>` — register a project: scans it, asks for what no scan can derive, and refuses to write an entry missing a mandatory field. `--name`, `--kind`, `--profile`, `--trust`, `-y`. |
173
+ | `etymd fleet dismiss` / `accept` | `<name> <id>` — resolve a project's finding from any cwd; corp findings persist beside the manifest, never in the corp worktree. |
166
174
 
167
175
  `--cwd <dir>` targets another directory. Read-only probing of any repo leaves **zero trace**
168
176
  (`audit --no-ledger` writes nothing).
169
177
 
170
- ## The files etymd keeps
178
+ ## The files Etymd keeps
171
179
 
172
180
  | Path | Lifecycle | Role |
173
181
  | ---------------------- | ------------- | ------------------------------------------------ |
@@ -180,7 +188,7 @@ The committed files are written to be publishable: the baseline records `"."` as
180
188
  your absolute machine path. Only the gitignored cache keeps the real one.
181
189
 
182
190
  Formatter interop: if your Prettier (or similar formatter) checks JSON, add `.etymd` to
183
- `.prettierignore` — etymd writes its own JSON style, and a format gate fighting the ledger is
191
+ `.prettierignore` — Etymd writes its own JSON style, and a format gate fighting the ledger is
184
192
  noise (this repo does exactly that).
185
193
 
186
194
  ### `.etymd/config.json` (optional)
@@ -191,7 +199,7 @@ Every key is optional; omit the file entirely and the defaults below apply.
191
199
  {
192
200
  "instructions": {
193
201
  // Audit these too — files detection would not find on its own.
194
- "include": ["design/**/*.md"],
202
+ "include": ["docs/handbook/**/*.md"],
195
203
  // Leave these out. The classic case: a fork that inherits upstream's skills
196
204
  // and will never fix them, but must keep its OWN instruction layer honest.
197
205
  "exclude": [".claude/skills/**"],
@@ -200,13 +208,27 @@ Every key is optional; omit the file entirely and the defaults below apply.
200
208
  "perFileWords": 4000, // extraction candidate above this
201
209
  "totalWords": 8000, // always-loaded footprint budget
202
210
  },
211
+ "gates": {
212
+ // What `etymd gates` generates. Written for you on first run from what the scan
213
+ // finds — edit it here rather than editing the generated hook, so the next run
214
+ // agrees with you instead of arguing.
215
+ "commands": ["typecheck", "lint"], // pre-push steps, in order
216
+ "failOn": "risk", // audit tier that fails the push: risk | gap | polish
217
+ "publishGate": true, // screen the published artifact
218
+ "allowWriting": [], // commands allowed into a gate despite writing
219
+ // Why a value here is what it is. Each key mirrors the field it explains, so the
220
+ // note says what it refers to instead of sitting near it and hoping. Etymd keeps
221
+ // these, and DROPS one whose field it changes — a reason attached to a value it no
222
+ // longer explains is worse than no reason at all.
223
+ "_why": { "failOn": "no build and no tests here; only docs drift can fire" },
224
+ },
203
225
  }
204
226
  ```
205
227
 
206
228
  Globs are repo-relative: `*` within a path segment, `**` across segments, `?` one character. A
207
229
  pattern with no wildcard is a **path prefix**, so `.claude/skills` covers everything beneath it.
208
230
 
209
- Narrowing an audit can hide findings, so etymd never lets it happen quietly. Honesty is
231
+ Narrowing an audit can hide findings, so Etymd never lets it happen quietly. Honesty is
210
232
  structural: **every excluded file is counted and named in the lens disclosures**, and a config
211
233
  that fails to parse is reported as a disclosure rather than silently falling back to defaults.
212
234
 
@@ -227,13 +249,71 @@ The ledger and baseline are not CI by-products — they are **committed, reviewa
227
249
  updated locally and read in CI. A dismissal (with its reason), an accepted finding, a baseline
228
250
  refresh after an intentional restructure: each lands in `.etymd/` and shows up in the pull
229
251
  request diff like any other change. Keep `.etymd` out of your formatter's reach (see
230
- [the files etymd keeps](#the-files-etymd-keeps)).
252
+ [the files Etymd keeps](#the-files-etymd-keeps)).
231
253
 
232
254
  A check that runs only in CI is itself a finding: the failure surfaces after the agent finished.
233
255
  `etymd gates` installs the local pre-commit / pre-push mirror built from your own check scripts,
234
256
  and the `gate-integrity` lens flags whatever still runs in CI alone.
235
257
 
236
- Modeled on this repo's own workflow (etymd guards its own instructions with etymd — its CI runs
258
+ ### Your own checks, beside the generated ones
259
+
260
+ Generated hooks are overwritten on every `etymd gates` run, so nothing hand-written belongs in
261
+ them. Each one calls a companion instead — `.githooks/pre-commit.local`, `commit-msg.local`,
262
+ `pre-push.local` — that etymd **never reads, writes, or regenerates**. Make it executable and it
263
+ runs; a non-zero exit stops the commit or push exactly as the generated checks do.
264
+
265
+ > **Commit the companion, and check your `.gitignore` first.** A `*.local` rule — common for env
266
+ > files, and shipped by some framework templates — silently swallows these too. The guard then
267
+ > works on the machine that wrote it and is absent for everyone who clones, which looks identical
268
+ > to having no guard at all. Add `!.githooks/*.local` if that rule exists.
269
+
270
+ ```sh
271
+ cat > .githooks/pre-commit.local <<'EOF'
272
+ #!/usr/bin/env sh
273
+ # Whatever this project needs — etymd will not touch this file.
274
+ ./scripts/check-changelog.sh || exit 1
275
+ EOF
276
+ chmod +x .githooks/pre-commit.local
277
+ ```
278
+
279
+ Two files, two owners. The generated half stays byte-identical to what the pack produces, which
280
+ is what lets drift detection say something precise: a difference there means the _managed_ part
281
+ was edited or went stale, never that you added a check of your own. Delete the companion and its
282
+ checks stop running — that is what deleting a file means, and etymd does not police a file it
283
+ does not own.
284
+
285
+ ### The content screen (`etymd screen`)
286
+
287
+ A separate question from "are the instructions true?": **does this repo carry text that must
288
+ never be published?** Absolute home paths, an employer's name, an internal hostname, an account
289
+ identifier — permanent the moment they are committed, because publishing exposes all history,
290
+ not the current tree.
291
+
292
+ Etymd ships the mechanism and **no patterns, ever**. The strings worth screening for are
293
+ themselves the sensitive material, so a built-in list would be useless to everyone else and a
294
+ leak for whoever wrote it. You supply a pattern file (one regex or literal per line, `#` for
295
+ comments) at `~/.config/etymd/screen-patterns` or via `--patterns`. Without one the command is
296
+ inert and says so — it never reports "clean" for a check it did not run.
297
+
298
+ `etymd gates` wires it into four doors, because a leak walks through whichever is unguarded:
299
+
300
+ | door | scope | what only it can catch |
301
+ | ---------------- | ------------------- | ------------------------------------------------------------- |
302
+ | `pre-commit` | staged file bytes | the ordinary case, at the cheapest moment to fix |
303
+ | `commit-msg` | the message itself | the staged scan reads file bytes and never sees the message |
304
+ | `pre-push` | every tracked file | anything committed with `--no-verify`, or merged in from else |
305
+ | `prepublishOnly` | the packed artifact | **a gitignored file that still ships** — see below |
306
+
307
+ That last door exists because the first three share a blind spot: they all answer "what is in
308
+ the repository?". `npm` and `vsce` do not honour `.gitignore`, so a local cache file can be
309
+ packaged into a published release while every git-scoped check passes forever.
310
+
311
+ Every generated hook resolves the screener at run time and **no-ops when it is absent**, so the
312
+ same hook file is safe to commit to a public repo: it carries no patterns and imposes no policy
313
+ on anyone who clones it. A deliberate exception is marked inline with `allow-published-string`,
314
+ visible in the diff rather than hidden in an allowlist.
315
+
316
+ Modeled on this repo's own workflow (Etymd guards its own instructions with Etymd — its CI runs
237
317
  the same gate against its own freshly built CLI):
238
318
 
239
319
  ```yaml
@@ -254,8 +334,8 @@ steps:
254
334
  agent-context file: claims about your fleet (what exists, where, under which **profile** — the
255
335
  side of the wall an entry belongs to, `personal` or `corp`; the **wall** is the placement
256
336
  boundary between personal and employer content that the sweep polices). It rots like any
257
- AGENTS.md does, and `etymd fleet` keeps it true. Design record:
258
- [`docs/design/004-fleet-truth-guard.md`](docs/design/004-fleet-truth-guard.md). Both the
337
+ AGENTS.md does, and `etymd fleet` keeps it true. Decision record:
338
+ [`docs/decisions/004-fleet-truth-guard.md`](docs/decisions/004-fleet-truth-guard.md). Both the
259
339
  registry schema and the fleet `--json` schema are **experimental through 0.2.x**.
260
340
 
261
341
  Two files beside each other — the split is the privacy model:
@@ -266,13 +346,21 @@ Two files beside each other — the split is the privacy model:
266
346
  {
267
347
  "registryVersion": 1,
268
348
  "root": "~/projects", // ~ expands on the consumer side — never a machine home
349
+ "orientation": { "root": "north" }, // optional: the entry every other entry is guided by
269
350
  "projects": [
270
- { "name": "web-app", "kind": "repo", "profile": "personal", "path": "web-app" },
351
+ {
352
+ "name": "web-app",
353
+ "kind": "repo",
354
+ "profile": "personal",
355
+ "path": "web-app",
356
+ "trust": "private",
357
+ },
271
358
  {
272
359
  "name": "notes",
273
360
  "kind": "docs",
274
361
  "profile": "personal",
275
362
  "path": "notes",
363
+ "trust": "private", // mandatory on every non-corp entry — see below
276
364
  "staleAfterDays": 45, // per-entry freshness window
277
365
  "contract": { "state": "STATUS.md" }, // native conventions register, never migrate
278
366
  },
@@ -302,6 +390,26 @@ Two files beside each other — the split is the privacy model:
302
390
  }
303
391
  ```
304
392
 
393
+ Two fields the scan can never derive, so the manifest must declare them:
394
+
395
+ - **`trust` — mandatory on every non-corp entry** (`public-repo` | `public-bound` | `private`).
396
+ It is a _safety predicate_, not a label: it decides whether content screening applies, so an
397
+ absent value is reported (`fleet check` flags it), never read as a silent `private`.
398
+ `public-bound` means private today, plausibly public later — screened exactly as hard as
399
+ public, because publishing exposes _all_ history: the scrub has to precede the first commit,
400
+ not the visibility flip. A value outside the vocabulary is flagged rather than coerced, so a
401
+ typo can never quietly disable screening. Corp entries omit it — `profile: "corp"` already
402
+ implies the answer.
403
+ - **`orientation.root` — optional, declared once.** Names the one entry every other entry is
404
+ guided by. Declared at the manifest level rather than repeated per entry, because a per-entry
405
+ link carries no information and can be forgotten: hoisting it makes an unoriented project
406
+ unrepresentable instead of merely detectable. Fleets without an orientation root omit the
407
+ block — etymd never assumes one.
408
+
409
+ `etymd fleet add <dir>` is the gate that keeps both true: it scans the project, prompts for what
410
+ no scan can derive, and **refuses to write an incomplete entry**. Non-interactive runs (`--yes`,
411
+ CI) must pass every mandatory value as a flag — there is deliberately no default.
412
+
305
413
  How the sweep behaves:
306
414
 
307
415
  - **Read-only by default, everywhere.** The sweep never creates `.etymd` anywhere.
@@ -329,7 +437,7 @@ How the sweep behaves:
329
437
  is deliberately no env var and no home-directory pointer.
330
438
 
331
439
  Formatter interop for the `.etymd` state the sweep resolves: same rule as everywhere — see
332
- [the files etymd keeps](#the-files-etymd-keeps).
440
+ [the files Etymd keeps](#the-files-etymd-keeps).
333
441
 
334
442
  ## Programmatic use
335
443
 
@@ -342,7 +450,7 @@ console.log(audit.findings) // one schema: claim · evidence · why · action ·
342
450
 
343
451
  ## The corpus (how this is validated)
344
452
 
345
- etymd is developed against a corpus of real sibling repos rather than fixtures alone —
453
+ Etymd is developed against a corpus of real sibling repos rather than fixtures alone —
346
454
  [`sources.json`](sources.json) lists them by shape. Every heuristic here exists because a real
347
455
  repo proved the previous one wrong, and each skip class in the truth lens is a false positive that
348
456
  a corpus run caught.
@@ -355,13 +463,13 @@ mapping each name to its sibling directory:
355
463
  { "dirs": { "nx-monorepo": "my-monorepo-checkout" } }
356
464
  ```
357
465
 
358
- Each value is resolved as a sibling of the etymd checkout.
466
+ Each value is resolved as a sibling of the Etymd checkout.
359
467
 
360
468
  Without it — on a fresh clone or in CI — those suites skip cleanly and the rest still run.
361
469
 
362
- ## Design record & roadmap
470
+ ## Decision record & roadmap
363
471
 
364
- [`docs/design/`](docs/design/) — 001 founding · 002 foundation re-lock · **003 the truth-guard
472
+ [`docs/decisions/`](docs/decisions/) — 001 founding · 002 foundation re-lock · **003 the truth-guard
365
473
  pivot** (the current identity; includes the state-of-the-field investigation it rests on) ·
366
474
  **004 fleet mode** (the truth guard across your repositories).
367
475
  [`ROADMAP.md`](ROADMAP.md) — what's now / next / later, the pre-publish checklist, and the
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { scanProject, PACK_VERSION } from './chunk-BH37LELV.js';
3
- import { VERSION } from './chunk-LVLZLCND.js';
4
- import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-FZSIOCOZ.js';
5
- import { section, theme, print, renderBaselineDrift, glyph } from './chunk-LBRNZZZF.js';
2
+ import { section, theme, print, renderBaselineDrift, glyph } from './chunk-TADKOW6P.js';
3
+ import { scanProject } from './chunk-HOLFW5QY.js';
4
+ import { VERSION } from './chunk-DKC7MLWG.js';
5
+ import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-R74SJ7HS.js';
6
+ import { PACK_VERSION } from './chunk-CRJHIXJH.js';
7
+ import './chunk-YLNQQZ5F.js';
6
8
 
7
9
  // src/commands/approve.ts
8
10
  async function run(opts) {
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-MEJ4MBVL.js';
3
+ import './chunk-V6NZEIHF.js';
4
+ import './chunk-LT67FU2Y.js';
5
+ import './chunk-WWBF4Y7K.js';
6
+ import './chunk-NMZ3RHWW.js';
7
+ import './chunk-TADKOW6P.js';
8
+ import './chunk-HOLFW5QY.js';
9
+ import './chunk-DKC7MLWG.js';
10
+ import './chunk-R74SJ7HS.js';
11
+ import './chunk-CRJHIXJH.js';
12
+ import './chunk-YLNQQZ5F.js';
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { scanProject } from './chunk-BH37LELV.js';
3
- import './chunk-LVLZLCND.js';
4
- import { ETYMD_DIR } from './chunk-FZSIOCOZ.js';
5
- import { print, theme } from './chunk-LBRNZZZF.js';
2
+ import { print, theme } from './chunk-TADKOW6P.js';
3
+ import { scanProject } from './chunk-HOLFW5QY.js';
4
+ import './chunk-DKC7MLWG.js';
5
+ import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
6
+ import './chunk-CRJHIXJH.js';
7
+ import './chunk-YLNQQZ5F.js';
6
8
  import { promises } from 'node:fs';
7
9
  import path from 'node:path';
8
10
 
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // src/pack/version.ts
3
+ var PACK_VERSION = "3";
4
+
5
+ export { PACK_VERSION };
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.2.2"};
4
+ version: "0.3.1"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { promises } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ async function applyFiles(root, files, overwrite = /* @__PURE__ */ new Set()) {
6
+ const written = [];
7
+ const skipped = [];
8
+ for (const file of files) {
9
+ if (file.exists && !overwrite.has(file.path)) {
10
+ skipped.push(file.path);
11
+ continue;
12
+ }
13
+ const abs = path.join(root, file.path);
14
+ await promises.mkdir(path.dirname(abs), { recursive: true });
15
+ await promises.writeFile(abs, file.contents, "utf8");
16
+ if (file.executable) await promises.chmod(abs, 493);
17
+ written.push(file.path);
18
+ }
19
+ return { written, skipped };
20
+ }
21
+
22
+ export { applyFiles };
@@ -1,11 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-LVLZLCND.js';
3
- import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-LBRNZZZF.js';
2
+ import { VERSION } from './chunk-DKC7MLWG.js';
3
+ import { PACK_VERSION } from './chunk-CRJHIXJH.js';
4
+ import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-YLNQQZ5F.js';
4
5
  import path from 'node:path';
5
6
  import { promises } from 'node:fs';
6
7
 
7
- // src/pack/version.ts
8
- var PACK_VERSION = "2";
9
8
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
10
9
  "node_modules",
11
10
  ".git",
@@ -564,10 +563,14 @@ async function scanProject(root, opts = {}) {
564
563
  commands: classifyCommands(rootPkg?.scripts),
565
564
  ci,
566
565
  hooks,
566
+ publishable: Boolean(rootPkg) && rootPkg?.private !== true,
567
+ // `engines.vscode` is what makes a package an extension, and extensions publish through
568
+ // vsce — which never runs prepublishOnly.
569
+ publishRoute: !rootPkg ? "none" : rootPkg.engines?.vscode ? "vscode" : "npm",
567
570
  artifacts,
568
571
  freshness,
569
572
  tree
570
573
  };
571
574
  }
572
575
 
573
- export { PACK_VERSION, expandFileGlobs, scanProject };
576
+ export { expandFileGlobs, scanProject };
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
3
+ import { readText, wordCount, approxTokens, pathExists } from './chunk-YLNQQZ5F.js';
4
+ import { promises } from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ var ALWAYS_LOADED = [
8
+ { path: "AGENTS.md", role: "operating contract" },
9
+ { path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
10
+ { path: "CLAUDE.md", role: "Claude Code pointer" },
11
+ { path: "GEMINI.md", role: "Gemini pointer" },
12
+ { path: ".github/copilot-instructions.md", role: "Copilot instructions" },
13
+ { path: ".cursorrules", role: "Cursor rules (legacy)" }
14
+ ];
15
+ var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
16
+ function isAlwaysAppliedCursorRule(text) {
17
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
18
+ if (!fm) return true;
19
+ const frontmatter = fm[1] ?? "";
20
+ return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
21
+ }
22
+ async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
23
+ const files = [];
24
+ for (const spec of ALWAYS_LOADED) {
25
+ const abs = path.join(root, spec.path);
26
+ const text = await readText(abs);
27
+ if (text === null) continue;
28
+ const words = wordCount(text);
29
+ files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
30
+ }
31
+ const rulesDir = path.join(root, ".cursor", "rules");
32
+ if (await pathExists(rulesDir)) {
33
+ try {
34
+ for (const entry of await promises.readdir(rulesDir)) {
35
+ if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
36
+ const text = await readText(path.join(rulesDir, entry));
37
+ if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
38
+ const words = wordCount(text);
39
+ files.push({
40
+ path: `.cursor/rules/${entry}`,
41
+ role: "Cursor rule (always applied)",
42
+ words,
43
+ approxTokens: approxTokens(words)
44
+ });
45
+ }
46
+ } catch {
47
+ }
48
+ }
49
+ const totalWords = files.reduce((s, f) => s + f.words, 0);
50
+ return {
51
+ files: files.sort((a, b) => b.words - a.words),
52
+ totalWords,
53
+ totalApproxTokens: approxTokens(totalWords),
54
+ perFileWords,
55
+ extractionCandidates: files.filter((f) => f.words >= perFileWords)
56
+ };
57
+ }
58
+
59
+ export { measureContext };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-KMAJIQHC.js';
3
- import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-LBRNZZZF.js';
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-V6NZEIHF.js';
3
+ import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-TADKOW6P.js';
4
4
 
5
5
  // src/commands/audit.ts
6
6
  async function run(opts) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readLedger, resolveEntry, writeLedger } from './chunk-KBF3SW3N.js';
3
- import { print, renderLedger, glyph, theme } from './chunk-LBRNZZZF.js';
2
+ import { readLedger, resolveEntry, writeLedger } from './chunk-WWBF4Y7K.js';
3
+ import { print, renderLedger, glyph, theme } from './chunk-TADKOW6P.js';
4
4
 
5
5
  // src/commands/ledger.ts
6
6
  async function list(opts) {