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/dist/index.js CHANGED
@@ -20,7 +20,7 @@ var __export = (target, all) => {
20
20
  var PACK_VERSION;
21
21
  var init_version = __esm({
22
22
  "src/pack/version.ts"() {
23
- PACK_VERSION = "8";
23
+ PACK_VERSION = "12";
24
24
  }
25
25
  });
26
26
 
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "etymd",
33
- version: "0.13.0",
33
+ version: "0.14.0",
34
34
  description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
35
35
  keywords: [
36
36
  "cli",
@@ -1136,15 +1136,36 @@ ${[
1136
1136
  "md"
1137
1137
  );
1138
1138
  }
1139
+ function isSelfBuildRepo(facts) {
1140
+ return facts.publishRoute !== "none" && facts.name === SELF_PACKAGE_NAME;
1141
+ }
1142
+ function contentGateResolution(selfBuild) {
1143
+ if (!selfBuild) return `GATE="\${CONTENT_GATE:-$(command -v etymd || true)}"`;
1144
+ return `GATE="\${CONTENT_GATE:-$(if [ -x ./dist/cli.js ]; then echo ./dist/cli.js; else command -v etymd || true; fi)}"`;
1145
+ }
1146
+ function contentScreenCall(opts) {
1147
+ const { args, envVar, blocking, indent } = opts;
1148
+ const hint = `etymd: this checker does not understand 'screen' (needs etymd 0.11+) \u2014 upgrade it, or set ${envVar} to a checker that does.`;
1149
+ return [
1150
+ `${indent}if ! "$GATE" screen ${args}; then`,
1151
+ `${indent} "$GATE" screen --help >/dev/null 2>&1 ||`,
1152
+ `${indent} echo "${hint}" >&2`,
1153
+ ...blocking ? [`${indent} exit 1`] : [],
1154
+ `${indent}fi`
1155
+ ].join("\n");
1156
+ }
1139
1157
  function localHookCall(hook) {
1140
1158
  return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
1141
1159
  # is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
1160
+ # A guard running tests that build fixture repositories should scrub git's exported GIT_* names
1161
+ # first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
1162
+ # env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
1142
1163
  LOCAL="$(dirname "$0")/${hook}.local"
1143
1164
  if [ -x "$LOCAL" ]; then
1144
1165
  "$LOCAL" "$@" || exit 1
1145
1166
  fi`;
1146
1167
  }
1147
- function generatePreCommitHook() {
1168
+ function generatePreCommitHook(selfBuild = false) {
1148
1169
  return stampGenerated(`#!/usr/bin/env sh
1149
1170
  # etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
1150
1171
 
@@ -1156,9 +1177,9 @@ ${localHookCall("pre-commit")}
1156
1177
  # commit anywhere, active only where you opted in.
1157
1178
  #
1158
1179
  # Bypass, with a reason: git commit --no-verify
1159
- ${CONTENT_GATE_RESOLUTION}
1180
+ ${contentGateResolution(selfBuild)}
1160
1181
  if [ -x "$GATE" ]; then
1161
- "$GATE" screen --staged || exit 1
1182
+ ${contentScreenCall({ args: "--staged", envVar: "CONTENT_GATE", blocking: true, indent: " " })}
1162
1183
  fi
1163
1184
 
1164
1185
  exit 0
@@ -1199,7 +1220,7 @@ function generateCommitMsgHook(gates) {
1199
1220
  # Bypass, with a reason: git commit --no-verify
1200
1221
  GATE="\${COMMIT_MSG_GATE:-$(command -v etymd || true)}"
1201
1222
  if [ -x "$GATE" ]; then
1202
- "$GATE" screen --message "$1" || exit 1
1223
+ ${contentScreenCall({ args: '--message "$1"', envVar: "COMMIT_MSG_GATE", blocking: true, indent: " " })}
1203
1224
  fi
1204
1225
  ${format}
1205
1226
  ${localHookCall("commit-msg")}
@@ -1210,11 +1231,24 @@ exit 0
1210
1231
  function shellcheckStep() {
1211
1232
  return `
1212
1233
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
1213
- # script added later is covered without regenerating this hook.
1234
+ # script added later is covered without regenerating this hook. zsh is NOT in the checked set:
1235
+ # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
1236
+ # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
1237
+ # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
1238
+ #
1239
+ # "the checker", not its name, on purpose: a comment whose first word is that name is read as
1240
+ # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
1241
+ # explains why it skips a shell dialect must not break the checker while doing it.
1214
1242
  if command -v shellcheck >/dev/null 2>&1; then
1215
1243
  scripts=$(git ls-files -z \\
1216
- | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da|z)?sh( |$)" && echo "{}"' \\
1244
+ | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da)?sh( |$)" && echo "{}"' \\
1217
1245
  | sort)
1246
+ zsh_scripts=$(git ls-files -z \\
1247
+ | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ]zsh( |$)" && echo "{}"' \\
1248
+ | sort)
1249
+ if [ -n "$zsh_scripts" ]; then
1250
+ echo "\u203A shellcheck: $(printf '%s\\n' "$zsh_scripts" | wc -l | tr -d ' ') zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1251
+ fi
1218
1252
  if [ -n "$scripts" ]; then
1219
1253
  echo "\u203A shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
1220
1254
  printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
@@ -1234,7 +1268,7 @@ else
1234
1268
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
1235
1269
  fi`;
1236
1270
  }
1237
- function generatePrePushHook(facts, gates) {
1271
+ function generatePrePushHook(facts, gates, selfBuild = false) {
1238
1272
  const run = runPrefix(facts.packageManager);
1239
1273
  const c = facts.commands;
1240
1274
  const candidates = gates?.commands?.length ? gates.commands : [c.formatCheck, c.typecheck, c.lint];
@@ -1243,8 +1277,9 @@ function generatePrePushHook(facts, gates) {
1243
1277
  (key) => Boolean(key) && (allowed.has(key) || isSafeGateCommand(c.raw[key]))
1244
1278
  ).map((key) => `${run} ${key}`);
1245
1279
  const shellStep = facts.shell?.scripts ? shellcheckStep() : "";
1280
+ const runner = steps.length ? SCRUBBED_RUNNER : "";
1246
1281
  const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
1247
- ${s} || exit 1`).join("\n") : shellStep ? (
1282
+ run_gate ${s} || exit 1`).join("\n") : shellStep ? (
1248
1283
  // A repo whose executable surface is shell HAS a correctness command — it just is not in
1249
1284
  // package.json. Claiming "none detected" beside a step that is about to run would be the
1250
1285
  // tool contradicting itself.
@@ -1261,22 +1296,22 @@ fi`;
1261
1296
  return stampGenerated(`#!/usr/bin/env sh
1262
1297
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
1263
1298
 
1264
- ${localHookCall("pre-push")}
1299
+ ${localHookCall("pre-push")}${runner}
1265
1300
  ${body}${shellStep}
1266
1301
  ${auditStep}
1267
1302
 
1268
1303
  # Content screen, second pass \u2014 the WHOLE TREE rather than one diff. Catches anything committed
1269
1304
  # with --no-verify and anything a rebase or merge brought in from elsewhere. Advisory here (it
1270
1305
  # never blocks the push): the blocking decision belongs at commit time, where the fix is cheap.
1271
- ${CONTENT_GATE_RESOLUTION}
1306
+ ${contentGateResolution(selfBuild)}
1272
1307
  if [ -x "$GATE" ]; then
1273
- "$GATE" screen --tree --advisory || true
1308
+ ${contentScreenCall({ args: "--tree --advisory", envVar: "CONTENT_GATE", blocking: false, indent: " " })}
1274
1309
  fi
1275
1310
 
1276
1311
  exit 0
1277
1312
  `);
1278
1313
  }
1279
- function generateArtifactCheckScript() {
1314
+ function generateArtifactCheckScript(selfBuild = false) {
1280
1315
  return stampGenerated(`#!/usr/bin/env sh
1281
1316
  # etymd: content screen \u2014 the published ARTIFACT, not the repository.
1282
1317
  #
@@ -1287,7 +1322,7 @@ function generateArtifactCheckScript() {
1287
1322
  # .etymd-screen-allow entries (with provenance) if you must exempt a string.
1288
1323
  set -eu
1289
1324
 
1290
- ${CONTENT_GATE_RESOLUTION}
1325
+ ${contentGateResolution(selfBuild)}
1291
1326
  [ -x "$GATE" ] || { echo "\u203A artifact-check: no checker installed \u2014 skipping."; exit 0; }
1292
1327
 
1293
1328
  WORK=$(mktemp -d)
@@ -1300,16 +1335,16 @@ if [ -f package.json ]; then
1300
1335
  tar -xzf "$WORK"/*.tgz -C "$WORK" 2>/dev/null || true
1301
1336
  fi
1302
1337
 
1303
- "$GATE" screen --dir "$WORK" || exit 1
1338
+ ${contentScreenCall({ args: '--dir "$WORK"', envVar: "CONTENT_GATE", blocking: true, indent: "" })}
1304
1339
  exit 0
1305
1340
  `);
1306
1341
  }
1307
- var GENERATION_MARKER_RE, CONTENT_GATE_RESOLUTION, COMMIT_TYPES, SUBJECT_ADVISORY_LENGTH;
1342
+ var GENERATION_MARKER_RE, SELF_PACKAGE_NAME, COMMIT_TYPES, SUBJECT_ADVISORY_LENGTH, SCRUBBED_RUNNER;
1308
1343
  var init_templates = __esm({
1309
1344
  "src/pack/templates.ts"() {
1310
1345
  init_version();
1311
1346
  GENERATION_MARKER_RE = /^(?:# |<!-- )etymd:generated pack-v\S+ ([0-9a-f]{16})(?: -->)?$/;
1312
- CONTENT_GATE_RESOLUTION = `GATE="\${CONTENT_GATE:-$(if [ -x ./dist/cli.js ]; then echo ./dist/cli.js; else command -v etymd || true; fi)}"`;
1347
+ SELF_PACKAGE_NAME = "etymd";
1313
1348
  COMMIT_TYPES = [
1314
1349
  "feat",
1315
1350
  "fix",
@@ -1324,6 +1359,14 @@ var init_templates = __esm({
1324
1359
  "revert"
1325
1360
  ];
1326
1361
  SUBJECT_ADVISORY_LENGTH = 72;
1362
+ SCRUBBED_RUNNER = `
1363
+ # Gate steps run scrubbed of git's exported GIT_* names: a child git that inherits them ignores
1364
+ # its cwd, so a hook-run suite building fixture repositories would operate on the real repo.
1365
+ run_gate() (
1366
+ # shellcheck disable=SC2046 # word-splitting is the point: one -u per exported GIT_* name
1367
+ env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') "$@"
1368
+ )
1369
+ `;
1327
1370
  }
1328
1371
  });
1329
1372
 
@@ -1386,7 +1429,13 @@ async function planWorkflow(root, facts, opts) {
1386
1429
  }
1387
1430
  if (opts.gates) {
1388
1431
  const existingPrePush = await readText(path2.join(root, ".githooks", "pre-push"));
1389
- await add(".githooks/pre-commit", generatePreCommitHook(), "Process gate (pre-commit)", true);
1432
+ const selfBuild = isSelfBuildRepo(facts);
1433
+ await add(
1434
+ ".githooks/pre-commit",
1435
+ generatePreCommitHook(selfBuild),
1436
+ "Process gate (pre-commit)",
1437
+ true
1438
+ );
1390
1439
  await add(
1391
1440
  ".githooks/commit-msg",
1392
1441
  generateCommitMsgHook(opts.gateConfig),
@@ -1402,14 +1451,14 @@ async function planWorkflow(root, facts, opts) {
1402
1451
  };
1403
1452
  await add(
1404
1453
  ".githooks/pre-push",
1405
- generatePrePushHook(facts, gateConfig),
1454
+ generatePrePushHook(facts, gateConfig, selfBuild),
1406
1455
  "Correctness gate (pre-push)",
1407
1456
  true
1408
1457
  );
1409
1458
  if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
1410
1459
  await add(
1411
1460
  "scripts/artifact-check.sh",
1412
- generateArtifactCheckScript(),
1461
+ generateArtifactCheckScript(selfBuild),
1413
1462
  "Content screen (published artifact)",
1414
1463
  true
1415
1464
  );
@@ -1446,14 +1495,36 @@ function isAlwaysAppliedCursorRule(text) {
1446
1495
  const frontmatter = fm[1] ?? "";
1447
1496
  return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
1448
1497
  }
1498
+ async function inodeKey(abs) {
1499
+ try {
1500
+ const st = await promises.stat(abs);
1501
+ return st.ino ? `${st.dev}:${st.ino}` : null;
1502
+ } catch {
1503
+ return null;
1504
+ }
1505
+ }
1506
+ function contextFileLabel(file) {
1507
+ return file.aliases?.length ? [file.path, ...file.aliases].join(" \u2192 ") : file.path;
1508
+ }
1449
1509
  async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
1450
1510
  const files = [];
1511
+ const byInode = /* @__PURE__ */ new Map();
1512
+ const record = async (rel, role, text) => {
1513
+ const words = wordCount(text);
1514
+ const key = await inodeKey(path2.join(root, rel));
1515
+ const seen = key ? byInode.get(key) : void 0;
1516
+ if (seen) {
1517
+ seen.aliases = [...seen.aliases ?? [], rel];
1518
+ return;
1519
+ }
1520
+ const file = { path: rel, role, words, approxTokens: approxTokens(words) };
1521
+ if (key) byInode.set(key, file);
1522
+ files.push(file);
1523
+ };
1451
1524
  for (const spec of ALWAYS_LOADED) {
1452
- const abs = path2.join(root, spec.path);
1453
- const text = await readText(abs);
1525
+ const text = await readText(path2.join(root, spec.path));
1454
1526
  if (text === null) continue;
1455
- const words = wordCount(text);
1456
- files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
1527
+ await record(spec.path, spec.role, text);
1457
1528
  }
1458
1529
  const rulesDir = path2.join(root, ".cursor", "rules");
1459
1530
  if (await pathExists(rulesDir)) {
@@ -1462,13 +1533,7 @@ async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
1462
1533
  if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
1463
1534
  const text = await readText(path2.join(rulesDir, entry));
1464
1535
  if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
1465
- const words = wordCount(text);
1466
- files.push({
1467
- path: `.cursor/rules/${entry}`,
1468
- role: "Cursor rule (always applied)",
1469
- words,
1470
- approxTokens: approxTokens(words)
1471
- });
1536
+ await record(`.cursor/rules/${entry}`, "Cursor rule (always applied)", text);
1472
1537
  }
1473
1538
  } catch {
1474
1539
  }
@@ -1525,13 +1590,15 @@ var contextEconomyLens = {
1525
1590
  const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
1526
1591
  const budget = await measureContext(ctx.root, budgets.perFileWords);
1527
1592
  const findings = [];
1593
+ const aliased = budget.files.filter((f) => f.aliases?.length);
1528
1594
  for (const f of budget.extractionCandidates) {
1595
+ const label = contextFileLabel(f);
1529
1596
  findings.push({
1530
1597
  id: `${LENS_ID}/heavy-file:${f.path}`,
1531
1598
  lens: LENS_ID,
1532
1599
  tier: "gap",
1533
- claim: `${f.path} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
1534
- evidence: [`${f.path}: ${f.words} words`],
1600
+ claim: `${label} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
1601
+ evidence: [`${label}: ${f.words} words`],
1535
1602
  why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
1536
1603
  action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
1537
1604
  effort: "M",
@@ -1544,7 +1611,7 @@ var contextEconomyLens = {
1544
1611
  lens: LENS_ID,
1545
1612
  tier: "gap",
1546
1613
  claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
1547
- evidence: budget.files.slice(0, 5).map((f) => `${f.path}: ${f.words}w`),
1614
+ evidence: budget.files.slice(0, 5).map((f) => `${contextFileLabel(f)}: ${f.words}w`),
1548
1615
  why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
1549
1616
  action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
1550
1617
  effort: "M",
@@ -1559,6 +1626,9 @@ var contextEconomyLens = {
1559
1626
  status: "ran",
1560
1627
  disclosures: [
1561
1628
  ...ctx.config?.problems ?? [],
1629
+ ...aliased.map(
1630
+ (f) => `${contextFileLabel(f)} are one file (same inode) \u2014 counted once, at ${f.words} words.`
1631
+ ),
1562
1632
  `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.`
1563
1633
  ],
1564
1634
  findings
@@ -1966,6 +2036,73 @@ async function buildGateInventory(root, facts) {
1966
2036
  };
1967
2037
  }
1968
2038
 
2039
+ // src/lenses/gate-integrity/screener.ts
2040
+ init_util();
2041
+ var pExecFile2 = promisify(execFile);
2042
+ var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
2043
+ var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
2044
+ var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
2045
+ async function probeScreener(root, facts) {
2046
+ const absent = {
2047
+ present: false,
2048
+ doors: [],
2049
+ runner: null,
2050
+ source: null,
2051
+ answersScreen: null
2052
+ };
2053
+ const dir = facts.hooks.dir;
2054
+ if (!dir) return absent;
2055
+ const doors = [];
2056
+ let devBuildArm = false;
2057
+ for (const name of HOOK_FILES) {
2058
+ const text = await readText(path2.join(root, dir, name));
2059
+ if (!text || !SCREEN_CALL_RE.test(text)) continue;
2060
+ doors.push(`${dir}/${name}`);
2061
+ if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
2062
+ }
2063
+ if (!doors.length) return absent;
2064
+ let runner = null;
2065
+ let source = null;
2066
+ const override = process.env.CONTENT_GATE;
2067
+ if (override) {
2068
+ runner = override;
2069
+ source = "CONTENT_GATE";
2070
+ } else if (devBuildArm && await pathExists(path2.join(root, "dist", "cli.js"))) {
2071
+ runner = path2.join(root, "dist", "cli.js");
2072
+ source = "dev-build";
2073
+ } else {
2074
+ runner = "etymd";
2075
+ source = "path";
2076
+ }
2077
+ try {
2078
+ await pExecFile2(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2079
+ return { present: true, doors, runner, source, answersScreen: true };
2080
+ } catch (err) {
2081
+ const e = err;
2082
+ if (e.code === "ENOENT") {
2083
+ return {
2084
+ present: true,
2085
+ doors,
2086
+ runner: null,
2087
+ source,
2088
+ answersScreen: null,
2089
+ skipped: `no checker named \`${runner}\` is installed \u2014 the screen doors no-op here by design, and nothing was verified.`
2090
+ };
2091
+ }
2092
+ if (e.killed || e.code === "ETIMEDOUT") {
2093
+ return {
2094
+ present: true,
2095
+ doors,
2096
+ runner,
2097
+ source,
2098
+ answersScreen: null,
2099
+ skipped: `\`${runner} screen --help\` did not return within 10s \u2014 the runner was not verified either way.`
2100
+ };
2101
+ }
2102
+ return { present: true, doors, runner, source, answersScreen: false };
2103
+ }
2104
+ }
2105
+
1969
2106
  // src/lenses/gate-integrity/lens.ts
1970
2107
  var LENS_ID2 = "gate-integrity";
1971
2108
  var CORRECTNESS_TOOLS = ["typecheck", "lint", "format-check", "test"];
@@ -2108,6 +2245,28 @@ function deriveGateFindings(inv, disclosures = []) {
2108
2245
  function localToolInHook(inv, tool) {
2109
2246
  return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
2110
2247
  }
2248
+ function deriveScreenerFindings(probe, disclosures) {
2249
+ if (!probe.present) return [];
2250
+ if (probe.skipped) {
2251
+ disclosures.push(`Content screen: ${probe.skipped}`);
2252
+ return [];
2253
+ }
2254
+ if (probe.answersScreen !== false) return [];
2255
+ 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";
2256
+ return [
2257
+ finding({
2258
+ id: `${LENS_ID2}/content-screen-unrunnable`,
2259
+ tier: "risk",
2260
+ kind: "truth",
2261
+ claim: `The content screen resolves to \`${probe.runner}\`, which does not understand \`screen\` \u2014 the gate cannot run`,
2262
+ evidence: [...probe.doors, `${probe.runner} screen --help \u2192 failed (${via})`],
2263
+ 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.",
2264
+ 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.",
2265
+ effort: "S",
2266
+ confidence: "high"
2267
+ })
2268
+ ];
2269
+ }
2111
2270
  var gateIntegrityLens = {
2112
2271
  id: LENS_ID2,
2113
2272
  version: "1",
@@ -2148,6 +2307,7 @@ var gateIntegrityLens = {
2148
2307
  if (inv.ci.system === "none")
2149
2308
  disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
2150
2309
  const findings = deriveGateFindings(inv, disclosures);
2310
+ findings.push(...deriveScreenerFindings(await probeScreener(ctx.root, ctx.facts), disclosures));
2151
2311
  return {
2152
2312
  lens: LENS_ID2,
2153
2313
  version: "1",
@@ -2692,8 +2852,23 @@ var KNOWN_DOC_REFS = [
2692
2852
  "DECISIONS.md",
2693
2853
  "GEMINI.md"
2694
2854
  ];
2855
+ var PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/;
2695
2856
  function extractDocRefs(text) {
2696
- return KNOWN_DOC_REFS.filter((name) => text.includes(name));
2857
+ const refs = [];
2858
+ let tildeSkipped = 0;
2859
+ for (const name of KNOWN_DOC_REFS) {
2860
+ let claimed = false;
2861
+ let at = text.indexOf(name);
2862
+ while (at !== -1) {
2863
+ let head = at;
2864
+ while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1])) head -= 1;
2865
+ if (text[head] === "~") tildeSkipped += 1;
2866
+ else claimed = true;
2867
+ at = text.indexOf(name, at + name.length);
2868
+ }
2869
+ if (claimed) refs.push(name);
2870
+ }
2871
+ return { refs, tildeSkipped };
2697
2872
  }
2698
2873
 
2699
2874
  // src/lenses/instruction-truth/lens.ts
@@ -2816,6 +2991,7 @@ var instructionTruthLens = {
2816
2991
  const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
2817
2992
  const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
2818
2993
  let totalFilteredSkipped = 0;
2994
+ let totalTildeSkipped = 0;
2819
2995
  let binaryResolved = 0;
2820
2996
  let unverifiableCommands = 0;
2821
2997
  let gitignoredSkipped = 0;
@@ -2905,7 +3081,9 @@ var instructionTruthLens = {
2905
3081
  break;
2906
3082
  }
2907
3083
  }
2908
- for (const ref of extractDocRefs(file.text)) {
3084
+ const { refs: docRefs, tildeSkipped } = extractDocRefs(file.text);
3085
+ totalTildeSkipped += tildeSkipped;
3086
+ for (const ref of docRefs) {
2909
3087
  if (await pathExists(path2.join(root, ref))) continue;
2910
3088
  findings.push(
2911
3089
  finding2({
@@ -3010,6 +3188,11 @@ var instructionTruthLens = {
3010
3188
  `${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
3011
3189
  );
3012
3190
  }
3191
+ if (totalTildeSkipped) {
3192
+ disclosures.push(
3193
+ `${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.`
3194
+ );
3195
+ }
3013
3196
  if (stateDocs.length) {
3014
3197
  disclosures.push(
3015
3198
  `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"}.`
@@ -3037,7 +3220,7 @@ var instructionTruthLens = {
3037
3220
  );
3038
3221
  }
3039
3222
  disclosures.push(
3040
- `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.`
3223
+ `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.`
3041
3224
  );
3042
3225
  return {
3043
3226
  lens: LENS_ID4,
@@ -3470,7 +3653,7 @@ async function loadFleetManifest(manifestPath) {
3470
3653
  init_config();
3471
3654
  init_facts();
3472
3655
  init_util();
3473
- var pExecFile2 = promisify(execFile);
3656
+ var pExecFile3 = promisify(execFile);
3474
3657
  var FLEET_LENS = "fleet-manifest";
3475
3658
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
3476
3659
  var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
@@ -3525,7 +3708,7 @@ function stateBudgetsFor(entry) {
3525
3708
  async function gitGrepFiles(root, needle, mode = "fixed") {
3526
3709
  try {
3527
3710
  const matchFlag = mode === "regex" ? "-E" : "-F";
3528
- const { stdout } = await pExecFile2("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3711
+ const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3529
3712
  cwd: root,
3530
3713
  timeout: 8e3
3531
3714
  });
@@ -1,12 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-HRJJQCMT.js';
4
- import './chunk-DWL2IKZH.js';
5
- import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-5BVKFJWM.js';
6
- import { scanProject } from './chunk-YXOAPMQH.js';
7
- import { VERSION } from './chunk-D3R74TJ2.js';
8
- import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-JHZ2BN4U.js';
9
- import { PACK_VERSION } from './chunk-LSZGCKIQ.js';
3
+ import { planWorkflow } from './chunk-UFNETE6P.js';
4
+ import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
5
+ import { scanProject } from './chunk-HWC6PKJM.js';
6
+ import { VERSION } from './chunk-623W6KVC.js';
7
+ import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
8
+ import { PACK_VERSION } from './chunk-YQZDYDAK.js';
10
9
  import { git, readText } from './chunk-4VPBP6K6.js';
11
10
  import { promises } from 'fs';
12
11
  import path from 'path';
@@ -57,12 +56,14 @@ async function run(opts) {
57
56
  const hasContract = facts.artifacts.some((a) => a.id === "agents" && a.exists);
58
57
  let scaffoldAgents = false;
59
58
  if (!hasContract) {
60
- scaffoldAgents = opts.yes ? true : guard(
61
- await confirm({
62
- message: "No AGENTS.md found \u2014 scaffold a minimal one from the reckoning?",
63
- initialValue: true
64
- })
65
- );
59
+ if (opts.withAgents) scaffoldAgents = true;
60
+ else if (!opts.yes)
61
+ scaffoldAgents = guard(
62
+ await confirm({
63
+ message: "No AGENTS.md found \u2014 scaffold a minimal one from the reckoning?",
64
+ initialValue: true
65
+ })
66
+ );
66
67
  }
67
68
  let gates = false;
68
69
  if (facts.git.isRepo && facts.hooks.source === "none") {
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ export { accept, dismiss, list } from './chunk-HOR4M6EC.js';
3
+ import './chunk-3E2IPCRY.js';
4
+ import './chunk-HI7NWPRA.js';
5
+ import './chunk-P6ATKV2R.js';
6
+ import './chunk-4VPBP6K6.js';
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-HWC6PKJM.js';
3
+ import './chunk-623W6KVC.js';
4
+ import './chunk-YQZDYDAK.js';
5
+ import './chunk-4VPBP6K6.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { print, renderFacts, theme } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-YXOAPMQH.js';
4
- import './chunk-D3R74TJ2.js';
5
- import { writeCachedFacts } from './chunk-JHZ2BN4U.js';
6
- import './chunk-LSZGCKIQ.js';
2
+ import { print, renderFacts, theme } from './chunk-HI7NWPRA.js';
3
+ import { scanProject } from './chunk-HWC6PKJM.js';
4
+ import './chunk-623W6KVC.js';
5
+ import { writeCachedFacts } from './chunk-P6ATKV2R.js';
6
+ import './chunk-YQZDYDAK.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/scan.ts
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { print, glyph, theme, section } from './chunk-5BVKFJWM.js';
2
+ import { print, glyph, theme, section } from './chunk-HI7NWPRA.js';
3
+ import './chunk-P6ATKV2R.js';
3
4
  import { git, readText, pathExists } from './chunk-4VPBP6K6.js';
4
5
  import { promises } from 'fs';
5
6
  import path from 'path';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etymd",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Keep your agent instructions true — verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,12 +0,0 @@
1
- #!/usr/bin/env node
2
- export { run } from './chunk-DSAQ5S5D.js';
3
- import './chunk-PXRLOEN5.js';
4
- import './chunk-F75Q43BC.js';
5
- import './chunk-3E2IPCRY.js';
6
- import './chunk-DWL2IKZH.js';
7
- import './chunk-5BVKFJWM.js';
8
- import './chunk-YXOAPMQH.js';
9
- import './chunk-D3R74TJ2.js';
10
- import './chunk-JHZ2BN4U.js';
11
- import './chunk-LSZGCKIQ.js';
12
- import './chunk-4VPBP6K6.js';
@@ -1,59 +0,0 @@
1
- #!/usr/bin/env node
2
- import { DEFAULT_CONFIG } from './chunk-DWL2IKZH.js';
3
- import { readText, wordCount, approxTokens, pathExists } from './chunk-4VPBP6K6.js';
4
- import { promises } from 'fs';
5
- import path from '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 };