etymd 0.13.0 → 0.14.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/dist/{approve-YUT43YLC.js → approve-QDOH2E7V.js} +5 -5
  3. package/dist/audit-42OHF7ON.js +10 -0
  4. package/dist/{brief-Z5S6OY2M.js → brief-ZUQK3CFL.js} +5 -5
  5. package/dist/{chunk-D3R74TJ2.js → chunk-623W6KVC.js} +1 -1
  6. package/dist/{chunk-5BVKFJWM.js → chunk-HI7NWPRA.js} +79 -4
  7. package/dist/{chunk-2VLNI3L2.js → chunk-HOR4M6EC.js} +1 -1
  8. package/dist/{chunk-YXOAPMQH.js → chunk-HWC6PKJM.js} +2 -2
  9. package/dist/{chunk-PXRLOEN5.js → chunk-LT7JMVV3.js} +127 -11
  10. package/dist/{chunk-DWL2IKZH.js → chunk-P6ATKV2R.js} +65 -3
  11. package/dist/{chunk-DSAQ5S5D.js → chunk-PAMWS665.js} +2 -2
  12. package/dist/{chunk-HRJJQCMT.js → chunk-UFNETE6P.js} +69 -20
  13. package/dist/{chunk-LSZGCKIQ.js → chunk-YQZDYDAK.js} +1 -1
  14. package/dist/cli.js +20 -20
  15. package/dist/{config-XAH6PA5G.js → config-724Y3IOB.js} +1 -2
  16. package/dist/{context-F63RSIBH.js → context-JGKU4M7Z.js} +2 -4
  17. package/dist/doctor-CE5SD4JW.js +17 -0
  18. package/dist/{fleet-YQ35KGEP.js → fleet-F3XDEDCV.js} +10 -12
  19. package/dist/{gates-DU6SDH2M.js → gates-F4YPKWJR.js} +6 -7
  20. package/dist/generate-KEX75XNG.js +5 -0
  21. package/dist/index.d.ts +9 -1
  22. package/dist/index.js +223 -40
  23. package/dist/{init-XJEYU2HC.js → init-AHA7VRRI.js} +14 -13
  24. package/dist/ledger-T54JDHGP.js +6 -0
  25. package/dist/scan-QRTH6HAP.js +5 -0
  26. package/dist/{scan-B24RZAV2.js → scan-TVX3FKK4.js} +5 -5
  27. package/dist/{screen-CS6PSG7U.js → screen-E4FC7W5N.js} +2 -1
  28. package/package.json +1 -1
  29. package/dist/audit-YRT4SWSQ.js +0 -12
  30. package/dist/chunk-F75Q43BC.js +0 -59
  31. package/dist/chunk-JHZ2BN4U.js +0 -67
  32. package/dist/doctor-7YKDXSKM.js +0 -19
  33. package/dist/generate-PMCP37DC.js +0 -6
  34. package/dist/ledger-4FOZ5HAB.js +0 -5
  35. package/dist/scan-QDNN7ER3.js +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,99 @@
1
1
  # etymd
2
2
 
3
+ ## 0.14.0
4
+
5
+ Knowledge pack **v8 → v12**: every generated hook and script is regenerated by `etymd gates`.
6
+ Versions 9, 10 and 11 were claimed by work that was reworked before release and stay retired,
7
+ so a pack number cited in a baseline always means exactly one pack.
8
+
9
+ ### Minor Changes
10
+
11
+ - 3056213: Three fixes to the generated gates and the onboarding flow:
12
+
13
+ - **zsh is out of the shellcheck scan.** The generated pre-push discovered shell
14
+ scripts by a shebang pattern that included zsh, but shellcheck cannot parse zsh — SC1071 is a
15
+ parser-level error no inline directive silences — so a repo whose executable surface is zsh
16
+ could never push. zsh shebangs are now excluded from the checked set and the hook prints the
17
+ exclusion (count + reason) at run time instead of going quiet about coverage.
18
+ - **`~/` home paths are no longer repo file references.** Prose like "global rules in
19
+ `~/.claude/CLAUDE.md` apply on top" made the audit demand a repo-root CLAUDE.md that was
20
+ never meant to exist — the sentence points at the reader's machine. Home-path mentions of
21
+ well-known docs are now skipped and disclosed, like absolute tokens; one ordinary mention
22
+ still makes the doc a live claim.
23
+ - **`init` no longer scaffolds AGENTS.md unasked.** `init -y` in a repo without a contract used
24
+ to write template prose nobody reviewed (and baseline it, making later deletion read as
25
+ drift). The scaffold is now opt-in via `--with-agents`; interactive runs still ask first.
26
+
27
+ - aab88c5: A content gate that cannot run is now caught, and says why.
28
+
29
+ The screen doors resolve their checker at run time, from outside the repo — so whether the gate
30
+ will actually run is the one thing reading the hook cannot tell you. When the resolved checker
31
+ turns out not to be a screener, the commit door failed closed on that program's own bare error
32
+ and the push door, advisory by design, skipped its whole-tree pass in silence. A repo could read
33
+ as screened for months while nothing screened it.
34
+
35
+ Two changes, one class:
36
+
37
+ - **The hooks explain themselves.** After a failed screen — and only then, so a clean run pays
38
+ nothing — the hook asks the checker whether it understands `screen` at all. If it does not, it
39
+ prints a line naming the version floor (`screen` needs etymd 0.11+) and the override, instead of
40
+ leaving the checker's unexplained "unknown command" as the last word. A screener reporting a
41
+ real finding is left to speak for itself.
42
+ - **`etymd audit` and `etymd doctor` check reachability.** Gate integrity now resolves the screen
43
+ runner exactly as the hook would and asks it the same question, reporting a risk-tier finding
44
+ when a checker resolves but cannot screen. No checker installed stays the designed no-op —
45
+ disclosed, never a finding — and a probe that could not be carried out claims nothing.
46
+
47
+ - 6536081: Generated hooks no longer guess at the content screener by path.
48
+
49
+ The emitted content-screen resolution used to include a bare `[ -x ./dist/cli.js ]` check ahead
50
+ of the `etymd` on PATH. That path is simply where a great many CLI projects build, so any repo
51
+ that builds its own binary there had the hook invoke THAT binary as the screener: it does not
52
+ know `screen`, so the commit door failed closed on every commit, while the push door — which
53
+ ignores the screen's exit status by design — skipped the whole-tree pass in silence. The trap
54
+ armed itself on a plain dependency install, since that runs the repo's build.
55
+
56
+ Resolution is now `CONTENT_GATE`, then whatever `etymd` is on PATH. The dev-build arm remains
57
+ for the repo that develops the screener itself, where gating on an unreleased build is the point,
58
+ but it is decided at generation time from the manifest name and emitted nowhere else.
59
+
60
+ Regenerate with `etymd gates` to pick this up; a repo needing a different runner for one
61
+ invocation still has `CONTENT_GATE`.
62
+
63
+ - 0c0bcc6: gates: generated pre-push steps run scrubbed of git's exported GIT\_\* environment
64
+
65
+ Git exports `GIT_DIR` / `GIT_WORK_TREE` / `GIT_INDEX_FILE` / … to every hook it
66
+ runs, and a child `git` that inherits them ignores its cwd. A test suite that
67
+ builds fixture repositories by shelling out to `git` therefore operates on the
68
+ REAL repository — committing into it, moving its refs — while the same suite
69
+ outside a hook is harmless, which is exactly why the defect hides: nothing run
70
+ from CI ever reproduces it. This changes the generated pre-push (`etymd gates`):
71
+
72
+ - **Every gate step runs through a `run_gate()` helper** that strips every
73
+ exported `GIT_*` name before the command runs — each name found in the
74
+ environment, not a fixed list, because git adds variables over time. A step
75
+ that genuinely means the repository finds it again from its working
76
+ directory, which for a hook is the repo root.
77
+ - **The audit and shellcheck steps stay unscrubbed**, deliberately: audit
78
+ operates on the repo it is invoked in and never descends into fixtures, and
79
+ shellcheck's `git ls-files` must see the real repo.
80
+ - **The `.local` companion note documents the wrap**, so a hand-written guard
81
+ that runs tests can scrub the same way — the companion is where repo-owned
82
+ suites live.
83
+
84
+ Existing hooks pick this up on the next `etymd gates` run.
85
+
86
+ ### Patch Changes
87
+
88
+ - 1cae71b: Context economy counts a file once when two instruction names are the same file.
89
+
90
+ `AGENTS.md` symlinked to `CLAUDE.md` is the ordinary way to serve harnesses that read different
91
+ names. Both names were measured separately, which doubled the reported always-loaded footprint —
92
+ enough to manufacture an over-budget finding out of nothing — and fired the heavy-file finding
93
+ twice for one file. Candidates resolving to the same inode (symlink or hardlink) are now counted
94
+ once and reported under every name they answer to, in `etymd audit` and `etymd context` alike,
95
+ with the merge stated in the lens disclosures.
96
+
3
97
  ## 0.13.0
4
98
 
5
99
  ### Minor Changes
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { section, theme, print, renderBaselineDrift, glyph } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-YXOAPMQH.js';
4
- import { VERSION } from './chunk-D3R74TJ2.js';
5
- import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-JHZ2BN4U.js';
6
- import { PACK_VERSION } from './chunk-LSZGCKIQ.js';
2
+ import { section, theme, print, renderBaselineDrift, glyph } from './chunk-HI7NWPRA.js';
3
+ import { scanProject } from './chunk-HWC6PKJM.js';
4
+ import { VERSION } from './chunk-623W6KVC.js';
5
+ import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-P6ATKV2R.js';
6
+ import { PACK_VERSION } from './chunk-YQZDYDAK.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/approve.ts
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-PAMWS665.js';
3
+ import './chunk-LT7JMVV3.js';
4
+ import './chunk-3E2IPCRY.js';
5
+ import './chunk-HI7NWPRA.js';
6
+ import './chunk-HWC6PKJM.js';
7
+ import './chunk-623W6KVC.js';
8
+ import './chunk-P6ATKV2R.js';
9
+ import './chunk-YQZDYDAK.js';
10
+ import './chunk-4VPBP6K6.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { print, theme } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-YXOAPMQH.js';
4
- import './chunk-D3R74TJ2.js';
5
- import { ETYMD_DIR } from './chunk-JHZ2BN4U.js';
6
- import './chunk-LSZGCKIQ.js';
2
+ import { print, theme } from './chunk-HI7NWPRA.js';
3
+ import { scanProject } from './chunk-HWC6PKJM.js';
4
+ import './chunk-623W6KVC.js';
5
+ import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
6
+ import './chunk-YQZDYDAK.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
  import { promises } from 'fs';
9
9
  import path from 'path';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.13.0"};
4
+ version: "0.14.0"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -1,6 +1,77 @@
1
1
  #!/usr/bin/env node
2
+ import { DEFAULT_CONFIG } from './chunk-P6ATKV2R.js';
3
+ import { readText, pathExists, approxTokens, wordCount } from './chunk-4VPBP6K6.js';
4
+ import { promises } from 'fs';
5
+ import path from 'path';
2
6
  import pc from 'picocolors';
3
7
 
8
+ var ALWAYS_LOADED = [
9
+ { path: "AGENTS.md", role: "operating contract" },
10
+ { path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
11
+ { path: "CLAUDE.md", role: "Claude Code pointer" },
12
+ { path: "GEMINI.md", role: "Gemini pointer" },
13
+ { path: ".github/copilot-instructions.md", role: "Copilot instructions" },
14
+ { path: ".cursorrules", role: "Cursor rules (legacy)" }
15
+ ];
16
+ var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
17
+ function isAlwaysAppliedCursorRule(text) {
18
+ const fm = text.match(/^---\n([\s\S]*?)\n---/);
19
+ if (!fm) return true;
20
+ const frontmatter = fm[1] ?? "";
21
+ return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
22
+ }
23
+ async function inodeKey(abs) {
24
+ try {
25
+ const st = await promises.stat(abs);
26
+ return st.ino ? `${st.dev}:${st.ino}` : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function contextFileLabel(file) {
32
+ return file.aliases?.length ? [file.path, ...file.aliases].join(" \u2192 ") : file.path;
33
+ }
34
+ async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
35
+ const files = [];
36
+ const byInode = /* @__PURE__ */ new Map();
37
+ const record = async (rel, role, text) => {
38
+ const words = wordCount(text);
39
+ const key = await inodeKey(path.join(root, rel));
40
+ const seen = key ? byInode.get(key) : void 0;
41
+ if (seen) {
42
+ seen.aliases = [...seen.aliases ?? [], rel];
43
+ return;
44
+ }
45
+ const file = { path: rel, role, words, approxTokens: approxTokens(words) };
46
+ if (key) byInode.set(key, file);
47
+ files.push(file);
48
+ };
49
+ for (const spec of ALWAYS_LOADED) {
50
+ const text = await readText(path.join(root, spec.path));
51
+ if (text === null) continue;
52
+ await record(spec.path, spec.role, text);
53
+ }
54
+ const rulesDir = path.join(root, ".cursor", "rules");
55
+ if (await pathExists(rulesDir)) {
56
+ try {
57
+ for (const entry of await promises.readdir(rulesDir)) {
58
+ if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
59
+ const text = await readText(path.join(rulesDir, entry));
60
+ if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
61
+ await record(`.cursor/rules/${entry}`, "Cursor rule (always applied)", text);
62
+ }
63
+ } catch {
64
+ }
65
+ }
66
+ const totalWords = files.reduce((s, f) => s + f.words, 0);
67
+ return {
68
+ files: files.sort((a, b) => b.words - a.words),
69
+ totalWords,
70
+ totalApproxTokens: approxTokens(totalWords),
71
+ perFileWords,
72
+ extractionCandidates: files.filter((f) => f.words >= perFileWords)
73
+ };
74
+ }
4
75
  var theme = {
5
76
  brand: (s) => pc.magenta(pc.bold(s)),
6
77
  heading: (s) => pc.bold(s),
@@ -103,11 +174,13 @@ function renderContext(budget, threshold) {
103
174
  print(` ${theme.dim("no always-loaded agent files found \u2014 nothing loads every session yet")}`);
104
175
  return;
105
176
  }
106
- const pathWidth = maxWidth(budget.files.map((f) => f.path));
177
+ const pathWidth = maxWidth(budget.files.map((f) => contextFileLabel(f)));
107
178
  for (const f of budget.files) {
108
179
  const heavy = f.words >= threshold;
109
180
  const words = heavy ? theme.warn(`${f.words} w`) : `${f.words} w`;
110
- print(` ${pad(theme.info(f.path), pathWidth)} ${pad(words, 12)} ${theme.dim(f.role)}`);
181
+ print(
182
+ ` ${pad(theme.info(contextFileLabel(f)), pathWidth)} ${pad(words, 12)} ${theme.dim(f.role)}`
183
+ );
111
184
  }
112
185
  print();
113
186
  keyValues([
@@ -122,7 +195,9 @@ function renderContext(budget, threshold) {
122
195
  ` ${theme.dim("These load every session but are rarely all needed. Move to an on-demand skill:")}`
123
196
  );
124
197
  for (const f of budget.extractionCandidates) {
125
- print(` ${glyph.arrow} ${theme.info(f.path)} ${theme.dim(`(${f.words} words)`)}`);
198
+ print(
199
+ ` ${glyph.arrow} ${theme.info(contextFileLabel(f))} ${theme.dim(`(${f.words} words)`)}`
200
+ );
126
201
  }
127
202
  } else {
128
203
  print(` ${glyph.ok} ${theme.dim("lean \u2014 no single file is heavy enough to extract yet")}`);
@@ -262,4 +337,4 @@ function renderLedger(ledger) {
262
337
  }
263
338
  }
264
339
 
265
- export { TIER_BADGE, glyph, print, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme };
340
+ export { TIER_BADGE, contextFileLabel, glyph, measureContext, print, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readLedger, resolveEntry, writeLedger } from './chunk-3E2IPCRY.js';
3
- import { print, renderLedger, glyph, theme } from './chunk-5BVKFJWM.js';
3
+ import { print, renderLedger, glyph, theme } from './chunk-HI7NWPRA.js';
4
4
 
5
5
  // src/commands/ledger.ts
6
6
  async function list(opts) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-D3R74TJ2.js';
3
- import { PACK_VERSION } from './chunk-LSZGCKIQ.js';
2
+ import { VERSION } from './chunk-623W6KVC.js';
3
+ import { PACK_VERSION } from './chunk-YQZDYDAK.js';
4
4
  import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-4VPBP6K6.js';
5
5
  import path from 'path';
6
6
  import { promises } from 'fs';
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { measureContext } from './chunk-F75Q43BC.js';
3
2
  import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-3E2IPCRY.js';
4
- import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-DWL2IKZH.js';
5
- import { scanProject, expandFileGlobs } from './chunk-YXOAPMQH.js';
6
- import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-JHZ2BN4U.js';
7
- import { PACK_VERSION } from './chunk-LSZGCKIQ.js';
3
+ import { measureContext, contextFileLabel } from './chunk-HI7NWPRA.js';
4
+ import { scanProject, expandFileGlobs } from './chunk-HWC6PKJM.js';
5
+ import { DEFAULT_CONFIG, ETYMD_DIR, writeCachedFacts, readBaseline, readConfig, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE, CONFIG_FILE } from './chunk-P6ATKV2R.js';
6
+ import { PACK_VERSION } from './chunk-YQZDYDAK.js';
8
7
  import { pathExists, readJson, readText, isDirectory, matchesAnyGlob, git, normalizeRelPath, isCiEnvironment, isExecutable } from './chunk-4VPBP6K6.js';
9
8
  import path from 'path';
10
9
  import YAML from 'yaml';
10
+ import { execFile } from 'child_process';
11
+ import { promisify } from 'util';
11
12
  import { promises } from 'fs';
12
13
 
13
14
  // src/engine/finding.ts
@@ -39,13 +40,15 @@ var contextEconomyLens = {
39
40
  const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
40
41
  const budget = await measureContext(ctx.root, budgets.perFileWords);
41
42
  const findings = [];
43
+ const aliased = budget.files.filter((f) => f.aliases?.length);
42
44
  for (const f of budget.extractionCandidates) {
45
+ const label = contextFileLabel(f);
43
46
  findings.push({
44
47
  id: `${LENS_ID}/heavy-file:${f.path}`,
45
48
  lens: LENS_ID,
46
49
  tier: "gap",
47
- claim: `${f.path} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
48
- evidence: [`${f.path}: ${f.words} words`],
50
+ claim: `${label} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
51
+ evidence: [`${label}: ${f.words} words`],
49
52
  why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
50
53
  action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
51
54
  effort: "M",
@@ -58,7 +61,7 @@ var contextEconomyLens = {
58
61
  lens: LENS_ID,
59
62
  tier: "gap",
60
63
  claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
61
- evidence: budget.files.slice(0, 5).map((f) => `${f.path}: ${f.words}w`),
64
+ evidence: budget.files.slice(0, 5).map((f) => `${contextFileLabel(f)}: ${f.words}w`),
62
65
  why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
63
66
  action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
64
67
  effort: "M",
@@ -73,6 +76,9 @@ var contextEconomyLens = {
73
76
  status: "ran",
74
77
  disclosures: [
75
78
  ...ctx.config?.problems ?? [],
79
+ ...aliased.map(
80
+ (f) => `${contextFileLabel(f)} are one file (same inode) \u2014 counted once, at ${f.words} words.`
81
+ ),
76
82
  `Budgets: ${budgets.perFileWords} words/file, ${budgets.totalWords} words total (${budgets.perFileWords === DEFAULT_CONFIG.context.perFileWords && budgets.totalWords === DEFAULT_CONFIG.context.totalWords ? `defaults \u2014 override under \`context\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Scoped Cursor rules excluded.`
77
83
  ],
78
84
  findings
@@ -473,6 +479,70 @@ async function buildGateInventory(root, facts) {
473
479
  commitlintDep
474
480
  };
475
481
  }
482
+ var pExecFile = promisify(execFile);
483
+ var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
484
+ var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
485
+ var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
486
+ async function probeScreener(root, facts) {
487
+ const absent = {
488
+ present: false,
489
+ doors: [],
490
+ runner: null,
491
+ source: null,
492
+ answersScreen: null
493
+ };
494
+ const dir = facts.hooks.dir;
495
+ if (!dir) return absent;
496
+ const doors = [];
497
+ let devBuildArm = false;
498
+ for (const name of HOOK_FILES) {
499
+ const text = await readText(path.join(root, dir, name));
500
+ if (!text || !SCREEN_CALL_RE.test(text)) continue;
501
+ doors.push(`${dir}/${name}`);
502
+ if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
503
+ }
504
+ if (!doors.length) return absent;
505
+ let runner = null;
506
+ let source = null;
507
+ const override = process.env.CONTENT_GATE;
508
+ if (override) {
509
+ runner = override;
510
+ source = "CONTENT_GATE";
511
+ } else if (devBuildArm && await pathExists(path.join(root, "dist", "cli.js"))) {
512
+ runner = path.join(root, "dist", "cli.js");
513
+ source = "dev-build";
514
+ } else {
515
+ runner = "etymd";
516
+ source = "path";
517
+ }
518
+ try {
519
+ await pExecFile(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
520
+ return { present: true, doors, runner, source, answersScreen: true };
521
+ } catch (err) {
522
+ const e = err;
523
+ if (e.code === "ENOENT") {
524
+ return {
525
+ present: true,
526
+ doors,
527
+ runner: null,
528
+ source,
529
+ answersScreen: null,
530
+ skipped: `no checker named \`${runner}\` is installed \u2014 the screen doors no-op here by design, and nothing was verified.`
531
+ };
532
+ }
533
+ if (e.killed || e.code === "ETIMEDOUT") {
534
+ return {
535
+ present: true,
536
+ doors,
537
+ runner,
538
+ source,
539
+ answersScreen: null,
540
+ skipped: `\`${runner} screen --help\` did not return within 10s \u2014 the runner was not verified either way.`
541
+ };
542
+ }
543
+ return { present: true, doors, runner, source, answersScreen: false };
544
+ }
545
+ }
476
546
 
477
547
  // src/lenses/gate-integrity/lens.ts
478
548
  var LENS_ID2 = "gate-integrity";
@@ -616,6 +686,28 @@ function deriveGateFindings(inv, disclosures = []) {
616
686
  function localToolInHook(inv, tool) {
617
687
  return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
618
688
  }
689
+ function deriveScreenerFindings(probe, disclosures) {
690
+ if (!probe.present) return [];
691
+ if (probe.skipped) {
692
+ disclosures.push(`Content screen: ${probe.skipped}`);
693
+ return [];
694
+ }
695
+ if (probe.answersScreen !== false) return [];
696
+ const via = probe.source === "CONTENT_GATE" ? "resolved from the CONTENT_GATE override" : probe.source === "dev-build" ? "resolved to this repo's own ./dist/cli.js build" : "resolved from PATH";
697
+ return [
698
+ finding({
699
+ id: `${LENS_ID2}/content-screen-unrunnable`,
700
+ tier: "risk",
701
+ kind: "truth",
702
+ claim: `The content screen resolves to \`${probe.runner}\`, which does not understand \`screen\` \u2014 the gate cannot run`,
703
+ evidence: [...probe.doors, `${probe.runner} screen --help \u2192 failed (${via})`],
704
+ why: "The commit door fails closed on an error that explains nothing, and the push door \u2014 advisory by design \u2014 skips the whole-tree pass in silence, so the repo reads as screened when nothing screened it.",
705
+ action: "Install or upgrade etymd (`screen` needs 0.11+), or point CONTENT_GATE at a checker that provides it, then re-run `etymd gates` to refresh the hooks.",
706
+ effort: "S",
707
+ confidence: "high"
708
+ })
709
+ ];
710
+ }
619
711
  var gateIntegrityLens = {
620
712
  id: LENS_ID2,
621
713
  version: "1",
@@ -656,6 +748,7 @@ var gateIntegrityLens = {
656
748
  if (inv.ci.system === "none")
657
749
  disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
658
750
  const findings = deriveGateFindings(inv, disclosures);
751
+ findings.push(...deriveScreenerFindings(await probeScreener(ctx.root, ctx.facts), disclosures));
659
752
  return {
660
753
  lens: LENS_ID2,
661
754
  version: "1",
@@ -1186,8 +1279,23 @@ var KNOWN_DOC_REFS = [
1186
1279
  "DECISIONS.md",
1187
1280
  "GEMINI.md"
1188
1281
  ];
1282
+ var PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/;
1189
1283
  function extractDocRefs(text) {
1190
- return KNOWN_DOC_REFS.filter((name) => text.includes(name));
1284
+ const refs = [];
1285
+ let tildeSkipped = 0;
1286
+ for (const name of KNOWN_DOC_REFS) {
1287
+ let claimed = false;
1288
+ let at = text.indexOf(name);
1289
+ while (at !== -1) {
1290
+ let head = at;
1291
+ while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1])) head -= 1;
1292
+ if (text[head] === "~") tildeSkipped += 1;
1293
+ else claimed = true;
1294
+ at = text.indexOf(name, at + name.length);
1295
+ }
1296
+ if (claimed) refs.push(name);
1297
+ }
1298
+ return { refs, tildeSkipped };
1191
1299
  }
1192
1300
 
1193
1301
  // src/lenses/instruction-truth/lens.ts
@@ -1310,6 +1418,7 @@ var instructionTruthLens = {
1310
1418
  const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
1311
1419
  const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
1312
1420
  let totalFilteredSkipped = 0;
1421
+ let totalTildeSkipped = 0;
1313
1422
  let binaryResolved = 0;
1314
1423
  let unverifiableCommands = 0;
1315
1424
  let gitignoredSkipped = 0;
@@ -1399,7 +1508,9 @@ var instructionTruthLens = {
1399
1508
  break;
1400
1509
  }
1401
1510
  }
1402
- for (const ref of extractDocRefs(file.text)) {
1511
+ const { refs: docRefs, tildeSkipped } = extractDocRefs(file.text);
1512
+ totalTildeSkipped += tildeSkipped;
1513
+ for (const ref of docRefs) {
1403
1514
  if (await pathExists(path.join(root, ref))) continue;
1404
1515
  findings.push(
1405
1516
  finding2({
@@ -1504,6 +1615,11 @@ var instructionTruthLens = {
1504
1615
  `${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
1505
1616
  );
1506
1617
  }
1618
+ if (totalTildeSkipped) {
1619
+ disclosures.push(
1620
+ `${totalTildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) \u2014 machine-global files, not this repo's; skipped, not flagged.`
1621
+ );
1622
+ }
1507
1623
  if (stateDocs.length) {
1508
1624
  disclosures.push(
1509
1625
  `Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
@@ -1531,7 +1647,7 @@ var instructionTruthLens = {
1531
1647
  );
1532
1648
  }
1533
1649
  disclosures.push(
1534
- `Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`
1650
+ `Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
1535
1651
  );
1536
1652
  return {
1537
1653
  lens: LENS_ID4,
@@ -1,8 +1,70 @@
1
1
  #!/usr/bin/env node
2
- import { ETYMD_DIR } from './chunk-JHZ2BN4U.js';
3
- import { pathExists, readText } from './chunk-4VPBP6K6.js';
2
+ import { readJson, pathExists, readText } from './chunk-4VPBP6K6.js';
4
3
  import path from 'path';
4
+ import { promises } from 'fs';
5
5
 
6
+ var ETYMD_DIR = ".etymd";
7
+ var CACHE_DIR = path.join(ETYMD_DIR, "cache");
8
+ var CACHE_FACTS_FILE = path.join(CACHE_DIR, "facts.json");
9
+ var BASELINE_FILE = path.join(ETYMD_DIR, "baseline.json");
10
+ function cacheFactsPath(root) {
11
+ return path.join(root, CACHE_FACTS_FILE);
12
+ }
13
+ function baselinePath(root) {
14
+ return path.join(root, BASELINE_FILE);
15
+ }
16
+ async function writeJsonFile(target, data) {
17
+ await promises.mkdir(path.dirname(target), { recursive: true });
18
+ await promises.writeFile(target, JSON.stringify(data, null, 2) + "\n", "utf8");
19
+ return target;
20
+ }
21
+ async function writeCachedFacts(root, facts) {
22
+ return writeJsonFile(cacheFactsPath(root), facts);
23
+ }
24
+ function withoutMachinePath(facts) {
25
+ return { ...facts, root: "." };
26
+ }
27
+ function baselineCarriesMachinePath(baseline) {
28
+ const root = baseline.facts?.root;
29
+ return typeof root === "string" && root !== "." && (root.startsWith("/") || /^[A-Za-z]:[\\/]/.test(root));
30
+ }
31
+ async function writeBaseline(root, baseline) {
32
+ return writeJsonFile(baselinePath(root), {
33
+ ...baseline,
34
+ facts: withoutMachinePath(baseline.facts)
35
+ });
36
+ }
37
+ async function readBaseline(root) {
38
+ return readJson(baselinePath(root));
39
+ }
40
+ function deriveProfile(facts) {
41
+ return (facts.git.recentAuthors ?? 1) > 2 ? "team" : "solo";
42
+ }
43
+ var DRIFT_ROLES = ["test", "lint", "typecheck", "format", "formatCheck", "build", "dev"];
44
+ function summarizeBaselineDrift(old, fresh) {
45
+ const commands = [];
46
+ for (const role of DRIFT_ROLES) {
47
+ const from = old.commands[role];
48
+ const to = fresh.commands[role];
49
+ if (from !== to) commands.push({ role, from, to });
50
+ }
51
+ const presentIds = (f) => new Set(f.artifacts.filter((a) => a.exists).map((a) => a.id));
52
+ const oldArtifacts = presentIds(old);
53
+ const freshArtifacts = presentIds(fresh);
54
+ const labelOf = (id) => fresh.artifacts.find((a) => a.id === id)?.label ?? old.artifacts.find((a) => a.id === id)?.label ?? id;
55
+ const artifactsAdded = [...freshArtifacts].filter((id) => !oldArtifacts.has(id)).map(labelOf);
56
+ const artifactsRemoved = [...oldArtifacts].filter((id) => !freshArtifacts.has(id)).map(labelOf);
57
+ const oldDirs = new Set(old.tree.dirs.map((d) => d.name));
58
+ const freshDirs = new Set(fresh.tree.dirs.map((d) => d.name));
59
+ const dirsAdded = [...freshDirs].filter((d) => !oldDirs.has(d));
60
+ const dirsRemoved = [...oldDirs].filter((d) => !freshDirs.has(d));
61
+ return { commands, artifactsAdded, artifactsRemoved, dirsAdded, dirsRemoved };
62
+ }
63
+ function isDriftEmpty(d) {
64
+ return d.commands.length === 0 && d.artifactsAdded.length === 0 && d.artifactsRemoved.length === 0 && d.dirsAdded.length === 0 && d.dirsRemoved.length === 0;
65
+ }
66
+
67
+ // src/core/config.ts
6
68
  var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
7
69
  var DEFAULT_CONFIG = {
8
70
  instructions: { include: [], exclude: [] },
@@ -106,4 +168,4 @@ async function readConfig(root) {
106
168
  };
107
169
  }
108
170
 
109
- export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig };
171
+ export { BASELINE_FILE, CACHE_DIR, CONFIG_FILE, DEFAULT_CONFIG, ETYMD_DIR, baselineCarriesMachinePath, configPath, deriveProfile, isDriftEmpty, readBaseline, readConfig, summarizeBaselineDrift, writeBaseline, writeCachedFacts };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-PXRLOEN5.js';
3
- import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-5BVKFJWM.js';
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-LT7JMVV3.js';
3
+ import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-HI7NWPRA.js';
4
4
 
5
5
  // src/commands/audit.ts
6
6
  async function run(opts) {