etymd 0.13.0 → 0.15.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 (38) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +30 -19
  3. package/dist/{approve-YUT43YLC.js → approve-MA4Z3TBT.js} +5 -5
  4. package/dist/audit-YSALDC2L.js +11 -0
  5. package/dist/{brief-Z5S6OY2M.js → brief-DPZYSAMC.js} +5 -5
  6. package/dist/{chunk-PXRLOEN5.js → chunk-2RNQ6OLV.js} +146 -717
  7. package/dist/{chunk-DSAQ5S5D.js → chunk-CEO3BXQB.js} +3 -2
  8. package/dist/{chunk-D3R74TJ2.js → chunk-DBWDMIYO.js} +1 -1
  9. package/dist/{chunk-5BVKFJWM.js → chunk-HI7NWPRA.js} +79 -4
  10. package/dist/{chunk-2VLNI3L2.js → chunk-HOR4M6EC.js} +1 -1
  11. package/dist/chunk-IWG77WV3.js +758 -0
  12. package/dist/{chunk-DWL2IKZH.js → chunk-P6ATKV2R.js} +65 -3
  13. package/dist/{chunk-HRJJQCMT.js → chunk-UFNETE6P.js} +69 -20
  14. package/dist/{chunk-YXOAPMQH.js → chunk-Y6RZRED3.js} +2 -2
  15. package/dist/{chunk-LSZGCKIQ.js → chunk-YQZDYDAK.js} +1 -1
  16. package/dist/cli.js +42 -21
  17. package/dist/{config-XAH6PA5G.js → config-724Y3IOB.js} +1 -2
  18. package/dist/{context-F63RSIBH.js → context-JGKU4M7Z.js} +2 -4
  19. package/dist/doctor-Y3DWDEBT.js +18 -0
  20. package/dist/{fleet-YQ35KGEP.js → fleet-4FYZ3GBK.js} +11 -12
  21. package/dist/{gates-DU6SDH2M.js → gates-IBQ7HCAN.js} +6 -7
  22. package/dist/generate-KEX75XNG.js +5 -0
  23. package/dist/index.d.ts +173 -2
  24. package/dist/index.js +713 -198
  25. package/dist/{init-XJEYU2HC.js → init-H57H2JBE.js} +14 -13
  26. package/dist/ledger-T54JDHGP.js +6 -0
  27. package/dist/premise-VMC3UUIB.js +333 -0
  28. package/dist/scan-W4US23MU.js +5 -0
  29. package/dist/{scan-B24RZAV2.js → scan-XBOHDXAH.js} +5 -5
  30. package/dist/{screen-CS6PSG7U.js → screen-E4FC7W5N.js} +2 -1
  31. package/package.json +2 -2
  32. package/dist/audit-YRT4SWSQ.js +0 -12
  33. package/dist/chunk-F75Q43BC.js +0 -59
  34. package/dist/chunk-JHZ2BN4U.js +0 -67
  35. package/dist/doctor-7YKDXSKM.js +0 -19
  36. package/dist/generate-PMCP37DC.js +0 -6
  37. package/dist/ledger-4FOZ5HAB.js +0 -5
  38. 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,8 +30,8 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "etymd",
33
- version: "0.13.0",
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.",
33
+ version: "0.15.0",
34
+ description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
35
35
  keywords: [
36
36
  "cli",
37
37
  "ai",
@@ -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",
@@ -2164,6 +2324,8 @@ var gateIntegrityLens = {
2164
2324
  init_version();
2165
2325
  init_config();
2166
2326
  init_facts();
2327
+
2328
+ // src/lenses/instruction-truth/checks.ts
2167
2329
  init_util();
2168
2330
 
2169
2331
  // src/lenses/state-freshness.ts
@@ -2692,8 +2854,221 @@ var KNOWN_DOC_REFS = [
2692
2854
  "DECISIONS.md",
2693
2855
  "GEMINI.md"
2694
2856
  ];
2857
+ var PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/;
2695
2858
  function extractDocRefs(text) {
2696
- return KNOWN_DOC_REFS.filter((name) => text.includes(name));
2859
+ const refs = [];
2860
+ let tildeSkipped = 0;
2861
+ for (const name of KNOWN_DOC_REFS) {
2862
+ let claimed = false;
2863
+ let at = text.indexOf(name);
2864
+ while (at !== -1) {
2865
+ let head = at;
2866
+ while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1])) head -= 1;
2867
+ if (text[head] === "~") tildeSkipped += 1;
2868
+ else claimed = true;
2869
+ at = text.indexOf(name, at + name.length);
2870
+ }
2871
+ if (claimed) refs.push(name);
2872
+ }
2873
+ return { refs, tildeSkipped };
2874
+ }
2875
+
2876
+ // src/lenses/instruction-truth/checks.ts
2877
+ async function buildTruthEnv(root, facts) {
2878
+ const knownScripts = new Set(Object.keys(facts.commands.raw));
2879
+ for (const pkg of facts.packages) {
2880
+ const pkgJson = await readJson(
2881
+ path2.join(root, pkg.dir, "package.json")
2882
+ );
2883
+ for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
2884
+ }
2885
+ const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2886
+ const pathResolves = async (claim) => {
2887
+ for (const base of bases) {
2888
+ if (await pathExists(path2.join(base, claim))) return true;
2889
+ if (await pathExists(path2.join(base, "src", claim))) return true;
2890
+ if (await pathExists(path2.join(base, "scripts", claim))) return true;
2891
+ }
2892
+ return false;
2893
+ };
2894
+ const binResolves = async (name) => {
2895
+ for (const base of bases) {
2896
+ if (await pathExists(path2.join(base, "node_modules", ".bin", name))) return true;
2897
+ }
2898
+ return false;
2899
+ };
2900
+ const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
2901
+ const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
2902
+ return {
2903
+ root,
2904
+ facts,
2905
+ knownScripts,
2906
+ nodeModulesInstalled,
2907
+ manifestExists,
2908
+ bases,
2909
+ pathResolves,
2910
+ binResolves
2911
+ };
2912
+ }
2913
+ function emptyCounters() {
2914
+ return {
2915
+ filteredSkipped: 0,
2916
+ tildeSkipped: 0,
2917
+ binaryResolved: 0,
2918
+ unverifiableCommands: 0,
2919
+ gitignoredSkipped: 0,
2920
+ prospectiveSkipped: 0,
2921
+ placeholderSkipped: 0,
2922
+ qualifiedRefsSkipped: 0,
2923
+ unresolvableRefs: 0
2924
+ };
2925
+ }
2926
+ async function checkTextClaims(env, file, opts, counters) {
2927
+ const findings = [];
2928
+ const disclosures = [];
2929
+ const examined = [];
2930
+ const subject = opts.subject ?? file.path;
2931
+ const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
2932
+ counters.filteredSkipped += filteredSkipped;
2933
+ for (const [script, raw] of claimed) {
2934
+ if (env.knownScripts.has(script)) {
2935
+ examined.push({ kind: "script", value: script, exists: true });
2936
+ continue;
2937
+ }
2938
+ if (await env.binResolves(script)) {
2939
+ counters.binaryResolved += 1;
2940
+ examined.push({ kind: "script", value: script, exists: true });
2941
+ continue;
2942
+ }
2943
+ if (!env.nodeModulesInstalled && env.manifestExists) {
2944
+ counters.unverifiableCommands += 1;
2945
+ examined.push({ kind: "script", value: script, exists: null });
2946
+ continue;
2947
+ }
2948
+ examined.push({ kind: "script", value: script, exists: false });
2949
+ findings.push({
2950
+ lens: opts.lensId,
2951
+ id: `${opts.lensId}/stale-command:${file.path}:${script}`,
2952
+ tier: "risk",
2953
+ claim: `${subject} tells agents to run \`${script}\` \u2014 no such script exists`,
2954
+ evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
2955
+ why: opts.whyCommand ?? "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
2956
+ action: opts.actionCommand ?? "Update the instruction to the current script name (or restore the script).",
2957
+ effort: "S",
2958
+ confidence: "high"
2959
+ });
2960
+ }
2961
+ const { paths, prospective, placeholder } = extractPathClaims(file.text);
2962
+ counters.prospectiveSkipped += prospective.length;
2963
+ counters.placeholderSkipped += placeholder.length;
2964
+ const missing = [];
2965
+ for (const claim of paths) {
2966
+ if (await env.pathResolves(claim)) examined.push({ kind: "path", value: claim, exists: true });
2967
+ else missing.push(claim);
2968
+ }
2969
+ const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
2970
+ const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
2971
+ let pathFindings = 0;
2972
+ for (const claim of missing) {
2973
+ if (gitignored.has(claim)) {
2974
+ counters.gitignoredSkipped += 1;
2975
+ examined.push({ kind: "path", value: claim, exists: null });
2976
+ continue;
2977
+ }
2978
+ examined.push({ kind: "path", value: claim, exists: false });
2979
+ if (pathFindings >= opts.maxPathFindings) {
2980
+ if (pathFindings === opts.maxPathFindings) {
2981
+ disclosures.push(
2982
+ `${file.path}: more than ${opts.maxPathFindings} missing-path claims \u2014 truncated.`
2983
+ );
2984
+ pathFindings += 1;
2985
+ }
2986
+ continue;
2987
+ }
2988
+ pathFindings += 1;
2989
+ findings.push({
2990
+ lens: opts.lensId,
2991
+ id: `${opts.lensId}/stale-path:${file.path}:${claim}`,
2992
+ tier: opts.missingPathTier,
2993
+ claim: `${subject} references \`${claim}\` \u2014 it does not exist in the repo`,
2994
+ evidence: [file.path, `missing: ${claim}`],
2995
+ why: opts.whyPath ?? "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
2996
+ action: opts.actionPath ?? "Fix or remove the reference.",
2997
+ effort: "S",
2998
+ confidence: "medium"
2999
+ });
3000
+ }
3001
+ return { findings, disclosures, examined };
3002
+ }
3003
+ async function checkDocRefs(env, file, lensId, counters, subject = file.path) {
3004
+ const findings = [];
3005
+ const examined = [];
3006
+ const { refs, tildeSkipped } = extractDocRefs(file.text);
3007
+ counters.tildeSkipped += tildeSkipped;
3008
+ for (const ref of refs) {
3009
+ const exists = await pathExists(path2.join(env.root, ref));
3010
+ examined.push({ kind: "doc", value: ref, exists });
3011
+ if (exists) continue;
3012
+ findings.push({
3013
+ lens: lensId,
3014
+ id: `${lensId}/dangling-ref:${file.path}:${ref}`,
3015
+ tier: "gap",
3016
+ claim: `${subject} references ${ref} \u2014 no such file exists`,
3017
+ evidence: [file.path, `missing: ${ref}`],
3018
+ why: "The pointer chain agents follow breaks at a file they can never read.",
3019
+ action: `Create ${ref} or remove the reference.`,
3020
+ effort: "S",
3021
+ confidence: "high"
3022
+ });
3023
+ }
3024
+ return { findings, examined };
3025
+ }
3026
+ async function loadDecisionLedger(root, facts) {
3027
+ let ids = null;
3028
+ const sources = [];
3029
+ for (const artifact of facts.artifacts) {
3030
+ if (artifact.kind !== "decisions" || !artifact.exists) continue;
3031
+ const text = await readText(path2.join(root, artifact.path));
3032
+ if (text === null) continue;
3033
+ const entries = parseDecisionEntries(text);
3034
+ if (!entries.length) continue;
3035
+ ids ??= /* @__PURE__ */ new Set();
3036
+ for (const entry of entries) ids.add(entry.num);
3037
+ sources.push(artifact.path);
3038
+ }
3039
+ return { ids, sources };
3040
+ }
3041
+ function checkDecisionRefs(file, ledger, opts, counters) {
3042
+ const findings = [];
3043
+ const examined = [];
3044
+ const subject = opts.subject ?? file.path;
3045
+ const { refs, qualifiedSkipped } = extractDecisionRefs(file.text);
3046
+ counters.qualifiedRefsSkipped += qualifiedSkipped;
3047
+ if (!refs.size) return { findings, examined };
3048
+ if (!ledger.ids) {
3049
+ counters.unresolvableRefs += refs.size;
3050
+ for (const asWritten of refs.values()) {
3051
+ examined.push({ kind: "decision", value: asWritten, exists: null });
3052
+ }
3053
+ return { findings, examined };
3054
+ }
3055
+ for (const [num, asWritten] of refs) {
3056
+ const exists = ledger.ids.has(num);
3057
+ examined.push({ kind: "decision", value: asWritten, exists });
3058
+ if (exists) continue;
3059
+ findings.push({
3060
+ lens: opts.lensId,
3061
+ id: `${opts.lensId}/dead-decision-ref:${file.path}:${asWritten}`,
3062
+ tier: "gap",
3063
+ claim: `${subject} cites ${asWritten} \u2014 no such entry exists in ${ledger.sources.join(", ")}`,
3064
+ evidence: [file.path, `${ledger.sources.join(", ")}: no ${asWritten} entry`],
3065
+ why: opts.why ?? "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
3066
+ action: opts.action ?? "Fix the reference \u2014 or record the missing decision.",
3067
+ effort: "S",
3068
+ confidence: "medium"
3069
+ });
3070
+ }
3071
+ return { findings, examined };
2697
3072
  }
2698
3073
 
2699
3074
  // src/lenses/instruction-truth/lens.ts
@@ -2790,98 +3165,17 @@ var instructionTruthLens = {
2790
3165
  })
2791
3166
  );
2792
3167
  }
2793
- const knownScripts = new Set(Object.keys(facts.commands.raw));
2794
- for (const pkg of facts.packages) {
2795
- const pkgJson = await readJson(
2796
- path2.join(root, pkg.dir, "package.json")
2797
- );
2798
- for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
2799
- }
2800
- const pathResolves = async (claim) => {
2801
- const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2802
- for (const base of bases) {
2803
- if (await pathExists(path2.join(base, claim))) return true;
2804
- if (await pathExists(path2.join(base, "src", claim))) return true;
2805
- if (await pathExists(path2.join(base, "scripts", claim))) return true;
2806
- }
2807
- return false;
2808
- };
2809
- const binResolves = async (name) => {
2810
- const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2811
- for (const base of bases) {
2812
- if (await pathExists(path2.join(base, "node_modules", ".bin", name))) return true;
2813
- }
2814
- return false;
3168
+ const env = await buildTruthEnv(root, facts);
3169
+ const counters = emptyCounters();
3170
+ const claimOpts = {
3171
+ lensId: LENS_ID4,
3172
+ missingPathTier: "gap",
3173
+ maxPathFindings: MAX_PATH_FINDINGS_PER_FILE
2815
3174
  };
2816
- const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
2817
- const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
2818
- let totalFilteredSkipped = 0;
2819
- let binaryResolved = 0;
2820
- let unverifiableCommands = 0;
2821
- let gitignoredSkipped = 0;
2822
- let prospectiveSkipped = 0;
2823
- let placeholderSkipped = 0;
2824
3175
  const auditClaims = async (file) => {
2825
- const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
2826
- totalFilteredSkipped += filteredSkipped;
2827
- for (const [script, raw] of claimed) {
2828
- if (knownScripts.has(script)) continue;
2829
- if (await binResolves(script)) {
2830
- binaryResolved += 1;
2831
- continue;
2832
- }
2833
- if (!nodeModulesInstalled && manifestExists) {
2834
- unverifiableCommands += 1;
2835
- continue;
2836
- }
2837
- findings.push(
2838
- finding2({
2839
- id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
2840
- tier: "risk",
2841
- claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
2842
- evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
2843
- why: "An agent following this instruction runs a command that fails \u2014 or silently skips the check it was meant to run.",
2844
- action: "Update the instruction to the current script name (or restore the script).",
2845
- effort: "S",
2846
- confidence: "high"
2847
- })
2848
- );
2849
- }
2850
- const { paths, prospective, placeholder } = extractPathClaims(file.text);
2851
- prospectiveSkipped += prospective.length;
2852
- placeholderSkipped += placeholder.length;
2853
- const missing = [];
2854
- for (const claim of paths) {
2855
- if (!await pathResolves(claim)) missing.push(claim);
2856
- }
2857
- const ignoredOut = missing.length ? await git(root, ["check-ignore", ...missing]) : null;
2858
- const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
2859
- let pathFindings = 0;
2860
- for (const claim of missing) {
2861
- if (gitignored.has(claim)) {
2862
- gitignoredSkipped += 1;
2863
- continue;
2864
- }
2865
- if (pathFindings >= MAX_PATH_FINDINGS_PER_FILE) {
2866
- disclosures.push(
2867
- `${file.path}: more than ${MAX_PATH_FINDINGS_PER_FILE} missing-path claims \u2014 truncated.`
2868
- );
2869
- break;
2870
- }
2871
- pathFindings += 1;
2872
- findings.push(
2873
- finding2({
2874
- id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
2875
- tier: "gap",
2876
- claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
2877
- evidence: [file.path, `missing: ${claim}`],
2878
- why: "Agents navigate by these references; a dead path wastes a lookup and erodes trust in the rest of the file.",
2879
- action: "Fix or remove the reference.",
2880
- effort: "S",
2881
- confidence: "medium"
2882
- })
2883
- );
2884
- }
3176
+ const result = await checkTextClaims(env, file, claimOpts, counters);
3177
+ findings.push(...result.findings);
3178
+ disclosures.push(...result.disclosures);
2885
3179
  };
2886
3180
  for (const file of files) {
2887
3181
  await auditClaims(file);
@@ -2905,64 +3199,15 @@ var instructionTruthLens = {
2905
3199
  break;
2906
3200
  }
2907
3201
  }
2908
- for (const ref of extractDocRefs(file.text)) {
2909
- if (await pathExists(path2.join(root, ref))) continue;
2910
- findings.push(
2911
- finding2({
2912
- id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
2913
- tier: "gap",
2914
- claim: `${file.path} references ${ref} \u2014 no such file exists`,
2915
- evidence: [file.path, `missing: ${ref}`],
2916
- why: "The pointer chain agents follow breaks at a file they can never read.",
2917
- action: `Create ${ref} or remove the reference.`,
2918
- effort: "S",
2919
- confidence: "high"
2920
- })
2921
- );
2922
- }
3202
+ findings.push(...(await checkDocRefs(env, file, LENS_ID4, counters)).findings);
2923
3203
  }
2924
3204
  const auditedPaths = new Set(files.map((f) => f.path));
2925
3205
  const stateDocs = await listStateDocuments(root, facts);
2926
- let qualifiedRefsSkipped = 0;
2927
- let unresolvableRefs = 0;
2928
- let ledgerIds = null;
2929
- const ledgerSources = [];
2930
- if (stateDocs.length) {
2931
- for (const artifact of facts.artifacts) {
2932
- if (artifact.kind !== "decisions" || !artifact.exists) continue;
2933
- const text = await readText(path2.join(root, artifact.path));
2934
- if (text === null) continue;
2935
- const entries = parseDecisionEntries(text);
2936
- if (!entries.length) continue;
2937
- ledgerIds ??= /* @__PURE__ */ new Set();
2938
- for (const entry of entries) ledgerIds.add(entry.num);
2939
- ledgerSources.push(artifact.path);
2940
- }
2941
- }
3206
+ const ledger = stateDocs.length ? await loadDecisionLedger(root, facts) : { ids: null, sources: [] };
3207
+ const ledgerSources = ledger.sources;
2942
3208
  for (const doc of stateDocs) {
2943
3209
  if (!auditedPaths.has(doc.path)) await auditClaims(doc);
2944
- const { refs, qualifiedSkipped } = extractDecisionRefs(doc.text);
2945
- qualifiedRefsSkipped += qualifiedSkipped;
2946
- if (!refs.size) continue;
2947
- if (!ledgerIds) {
2948
- unresolvableRefs += refs.size;
2949
- continue;
2950
- }
2951
- for (const [num, asWritten] of refs) {
2952
- if (ledgerIds.has(num)) continue;
2953
- findings.push(
2954
- finding2({
2955
- id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
2956
- tier: "gap",
2957
- claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
2958
- evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
2959
- why: "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
2960
- action: "Fix the reference \u2014 or record the missing decision.",
2961
- effort: "S",
2962
- confidence: "medium"
2963
- })
2964
- );
2965
- }
3210
+ findings.push(...checkDecisionRefs(doc, ledger, { lensId: LENS_ID4 }, counters).findings);
2966
3211
  }
2967
3212
  if (ctx.baseline) {
2968
3213
  findings.push(
@@ -2985,29 +3230,34 @@ var instructionTruthLens = {
2985
3230
  "No committed baseline (.etymd/baseline.json) \u2014 drift over time is not measurable; run `etymd init` to approve one."
2986
3231
  );
2987
3232
  }
2988
- if (binaryResolved) {
3233
+ if (counters.binaryResolved) {
3234
+ disclosures.push(
3235
+ `${counters.binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
3236
+ );
3237
+ }
3238
+ if (counters.unverifiableCommands) {
2989
3239
  disclosures.push(
2990
- `${binaryResolved} command claim(s) are installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
3240
+ `node_modules is not installed \u2014 ${counters.unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
2991
3241
  );
2992
3242
  }
2993
- if (unverifiableCommands) {
3243
+ if (counters.gitignoredSkipped) {
2994
3244
  disclosures.push(
2995
- `node_modules is not installed \u2014 ${unverifiableCommands} command claim(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
3245
+ `${counters.gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
2996
3246
  );
2997
3247
  }
2998
- if (gitignoredSkipped) {
3248
+ if (counters.prospectiveSkipped) {
2999
3249
  disclosures.push(
3000
- `${gitignoredSkipped} missing path claim(s) are gitignored (machine-local, e.g. .env) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
3250
+ `${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
3001
3251
  );
3002
3252
  }
3003
- if (prospectiveSkipped) {
3253
+ if (counters.placeholderSkipped) {
3004
3254
  disclosures.push(
3005
- `${prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
3255
+ `${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
3006
3256
  );
3007
3257
  }
3008
- if (placeholderSkipped) {
3258
+ if (counters.tildeSkipped) {
3009
3259
  disclosures.push(
3010
- `${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
3260
+ `${counters.tildeSkipped} 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.`
3011
3261
  );
3012
3262
  }
3013
3263
  if (stateDocs.length) {
@@ -3015,14 +3265,14 @@ var instructionTruthLens = {
3015
3265
  `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"}.`
3016
3266
  );
3017
3267
  }
3018
- if (qualifiedRefsSkipped) {
3268
+ if (counters.qualifiedRefsSkipped) {
3019
3269
  disclosures.push(
3020
- `${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
3270
+ `${counters.qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
3021
3271
  );
3022
3272
  }
3023
- if (unresolvableRefs) {
3273
+ if (counters.unresolvableRefs) {
3024
3274
  disclosures.push(
3025
- `${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
3275
+ `${counters.unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
3026
3276
  );
3027
3277
  }
3028
3278
  if (excluded.length) {
@@ -3037,7 +3287,7 @@ var instructionTruthLens = {
3037
3287
  );
3038
3288
  }
3039
3289
  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.`
3290
+ `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 (${counters.filteredSkipped}); 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
3291
  );
3042
3292
  return {
3043
3293
  lens: LENS_ID4,
@@ -3470,7 +3720,7 @@ async function loadFleetManifest(manifestPath) {
3470
3720
  init_config();
3471
3721
  init_facts();
3472
3722
  init_util();
3473
- var pExecFile2 = promisify(execFile);
3723
+ var pExecFile3 = promisify(execFile);
3474
3724
  var FLEET_LENS = "fleet-manifest";
3475
3725
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
3476
3726
  var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
@@ -3525,7 +3775,7 @@ function stateBudgetsFor(entry) {
3525
3775
  async function gitGrepFiles(root, needle, mode = "fixed") {
3526
3776
  try {
3527
3777
  const matchFlag = mode === "regex" ? "-E" : "-F";
3528
- const { stdout } = await pExecFile2("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3778
+ const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3529
3779
  cwd: root,
3530
3780
  timeout: 8e3
3531
3781
  });
@@ -4075,8 +4325,273 @@ async function sweepFleet(manifest, opts = {}) {
4075
4325
  };
4076
4326
  }
4077
4327
 
4328
+ // src/engine/premise.ts
4329
+ init_facts();
4330
+ init_scan();
4331
+ init_util();
4332
+ var PREMISE_LENS = "premise";
4333
+ var PREMISE_BRIEF_FILE = path2.join(ETYMD_DIR, "premise-brief.md");
4334
+ var TASK_LABEL = "task";
4335
+ var MAX_PATH_FINDINGS = 15;
4336
+ var CODE_SPAN_RE = /(```[\s\S]*?```|`[^`\n]+`)/g;
4337
+ var RUN_RE = /\b(?:pnpm|yarn|npm|bun)\s+run\s+(?!-)([A-Za-z0-9:._-]+)|\bnpm\s+(?:test|start)\b/g;
4338
+ var BARE_PM_RE = /\b(?:pnpm|yarn|bun)\s+(?!run\b)(?!-)[A-Za-z0-9:._-]+/g;
4339
+ var STOP_WORDS = new Set(
4340
+ "a an the this that these those it its them all any both each every some your our my their and or but to in on at of for with from by as is are was were be will would can could should may might must do does did not no so if then than when where which who what how also again now first once twice after before via into over only just too very".split(" ")
4341
+ );
4342
+ var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
4343
+ var TRAIL_RE = /[.,;:!?)\]}"']*$/;
4344
+ var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
4345
+ function promoteBareTokens(text, ctx) {
4346
+ const skips = {
4347
+ bareInvocations: 0,
4348
+ proseScripts: 0,
4349
+ hostnameLike: 0,
4350
+ unrootedDirs: 0
4351
+ };
4352
+ const promoted = text.split(CODE_SPAN_RE).map((segment, i) => i % 2 === 1 ? segment : promoteProse(segment, ctx, skips)).join("");
4353
+ return { text: promoted, skips };
4354
+ }
4355
+ function promoteProse(segment, ctx, skips) {
4356
+ skips.bareInvocations += segment.match(BARE_PM_RE)?.length ?? 0;
4357
+ const withCommands = segment.replace(RUN_RE, (m, script) => {
4358
+ const trail = TRAIL_RE.exec(m)?.[0] ?? "";
4359
+ const core = trail ? m.slice(0, -trail.length) : m;
4360
+ if (script !== void 0) {
4361
+ const name = script.replace(TRAIL_RE, "");
4362
+ if (name.length < 2 || STOP_WORDS.has(name.toLowerCase())) {
4363
+ skips.proseScripts += 1;
4364
+ return m;
4365
+ }
4366
+ }
4367
+ return `\`${core}\`${trail}`;
4368
+ });
4369
+ return withCommands.replace(/[^\s`]+/g, (token) => {
4370
+ const m = WRAP_RE.exec(token);
4371
+ if (!m) return token;
4372
+ const [, lead = "", core = "", trail = ""] = m;
4373
+ if (!core || core.includes("://") || !PATH_TOKEN_RE.test(core)) return token;
4374
+ const first = core.slice(0, core.indexOf("/"));
4375
+ const hostSuffix = HOSTNAME_RE.exec(first)?.[1]?.toLowerCase();
4376
+ if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) {
4377
+ skips.hostnameLike += 1;
4378
+ return token;
4379
+ }
4380
+ if (core.endsWith("/")) {
4381
+ if (!ctx.rootedDirs.has(first)) {
4382
+ skips.unrootedDirs += 1;
4383
+ return token;
4384
+ }
4385
+ return `${lead}\`${core}\`${trail}`;
4386
+ }
4387
+ const ext = core.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
4388
+ if (!ext || !KNOWN_EXTENSIONS.has(ext)) return token;
4389
+ return `${lead}\`${core}\`${trail}`;
4390
+ });
4391
+ }
4392
+ async function listRootedDirs(env) {
4393
+ const dirs = /* @__PURE__ */ new Set();
4394
+ for (const base of env.bases) {
4395
+ for (const sub of ["", "src", "scripts"]) {
4396
+ const entries = await promises.readdir(path2.join(base, sub), { withFileTypes: true }).catch(() => []);
4397
+ for (const entry of entries) if (entry.isDirectory()) dirs.add(entry.name);
4398
+ }
4399
+ }
4400
+ return dirs;
4401
+ }
4402
+ async function runPremise(opts) {
4403
+ const root = opts.root;
4404
+ const task = opts.task.trim();
4405
+ const facts = await scanProject(root);
4406
+ const env = await buildTruthEnv(root, facts);
4407
+ const counters = emptyCounters();
4408
+ const promoted = promoteBareTokens(task, { rootedDirs: await listRootedDirs(env) });
4409
+ const text = { path: TASK_LABEL, text: promoted.text };
4410
+ const findings = [];
4411
+ const disclosures = [];
4412
+ const entities = [];
4413
+ const claims = await checkTextClaims(
4414
+ env,
4415
+ text,
4416
+ {
4417
+ lensId: PREMISE_LENS,
4418
+ subject: "The task",
4419
+ missingPathTier: "risk",
4420
+ maxPathFindings: MAX_PATH_FINDINGS,
4421
+ whyCommand: "The task is built on a command that does not exist \u2014 an agent will run it and fail, or quietly substitute something else and report success.",
4422
+ actionCommand: "Fix the task before handing it over: name the real script, or restore it.",
4423
+ whyPath: "The task is ABOUT this path. Acting on it means editing, testing, or reasoning about something that is not there \u2014 the wrong problem, solved precisely.",
4424
+ actionPath: "Find where the behaviour actually lives and rewrite the task to name it."
4425
+ },
4426
+ counters
4427
+ );
4428
+ findings.push(...claims.findings);
4429
+ disclosures.push(...claims.disclosures);
4430
+ entities.push(...claims.examined);
4431
+ const docs = await checkDocRefs(env, text, PREMISE_LENS, counters, "The task");
4432
+ findings.push(...docs.findings);
4433
+ entities.push(...docs.examined);
4434
+ const ledger = await loadDecisionLedger(root, facts);
4435
+ const decisions = checkDecisionRefs(
4436
+ text,
4437
+ ledger,
4438
+ {
4439
+ lensId: PREMISE_LENS,
4440
+ subject: "The task",
4441
+ why: "The task presupposes a ruling that was never recorded; an agent will act on a decision nobody made.",
4442
+ action: "Point the task at the entry that exists \u2014 or record the missing decision first."
4443
+ },
4444
+ counters
4445
+ );
4446
+ findings.push(...decisions.findings);
4447
+ entities.push(...decisions.examined);
4448
+ if (!entities.length) {
4449
+ disclosures.push(
4450
+ "The task names no path with a directory, no package script, no well-known doc, and no decision id \u2014 nothing etymd can check by reading the repo. Everything it presupposes is in the brief."
4451
+ );
4452
+ }
4453
+ disclosures.push(
4454
+ "In prose, a path needs a directory and a recognized extension (a directory claim needs a trailing slash and a first segment that exists here), and a script needs the `run` form (`npm run X`, `pnpm run X` \u2026) or the `npm test` / `npm start` shorthands; anything backticked is read exactly as an instruction file would be. A bare file name (a lone `config.ts`) is prose."
4455
+ );
4456
+ const { skips } = promoted;
4457
+ if (skips.bareInvocations) {
4458
+ disclosures.push(
4459
+ `${skips.bareInvocations} bare \`pnpm X\` / \`yarn X\` / \`bun X\` mention(s) in prose were not read as scripts \u2014 in a sentence that shape is a phrase as often as an invocation; write \`\u2026 run X\` or backtick it to have it checked.`
4460
+ );
4461
+ }
4462
+ if (skips.proseScripts) {
4463
+ disclosures.push(
4464
+ `${skips.proseScripts} \`\u2026 run X\` mention(s) put a function word or a one-letter stand-in where a script name would be; read as prose, skipped.`
4465
+ );
4466
+ }
4467
+ if (skips.hostnameLike) {
4468
+ disclosures.push(
4469
+ `${skips.hostnameLike} slash token(s) start with a host name (e.g. \`github.com/\u2026\`) \u2014 a URL, not a repo path; skipped.`
4470
+ );
4471
+ }
4472
+ if (skips.unrootedDirs) {
4473
+ disclosures.push(
4474
+ `${skips.unrootedDirs} slash-joined phrase(s) ending in \`/\` start with no directory that exists here (e.g. \`input/output/\`) \u2014 read as prose; skipped, not flagged. Backtick one to have it checked.`
4475
+ );
4476
+ }
4477
+ if (counters.unverifiableCommands) {
4478
+ disclosures.push(
4479
+ `node_modules is not installed \u2014 ${counters.unverifiableCommands} command(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
4480
+ );
4481
+ }
4482
+ if (counters.binaryResolved) {
4483
+ disclosures.push(
4484
+ `${counters.binaryResolved} command(s) resolve to installed binaries (node_modules/.bin), not package scripts \u2014 treated as true.`
4485
+ );
4486
+ }
4487
+ if (counters.gitignoredSkipped) {
4488
+ disclosures.push(
4489
+ `${counters.gitignoredSkipped} missing path(s) are gitignored (machine-local) \u2014 existence is not verifiable from the repo; skipped, not flagged.`
4490
+ );
4491
+ }
4492
+ if (counters.prospectiveSkipped) {
4493
+ disclosures.push(
4494
+ `${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
4495
+ );
4496
+ }
4497
+ if (counters.placeholderSkipped) {
4498
+ disclosures.push(
4499
+ `${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`
4500
+ );
4501
+ }
4502
+ if (counters.filteredSkipped) {
4503
+ disclosures.push(
4504
+ `${counters.filteredSkipped} workspace-filtered command(s) (\`--filter\`, \`-C\` \u2026) were not resolved; skipped.`
4505
+ );
4506
+ }
4507
+ if (counters.tildeSkipped) {
4508
+ disclosures.push(
4509
+ `${counters.tildeSkipped} 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.`
4510
+ );
4511
+ }
4512
+ if (counters.qualifiedRefsSkipped) {
4513
+ disclosures.push(
4514
+ `${counters.qualifiedRefsSkipped} decision reference(s) name another record \u2014 not this repo's; skipped.`
4515
+ );
4516
+ }
4517
+ if (counters.unresolvableRefs) {
4518
+ disclosures.push(
4519
+ `${counters.unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
4520
+ );
4521
+ }
4522
+ disclosures.push(
4523
+ "What the task PRESUPPOSES \u2014 that the named things are the ones meant, that the mechanism it assumes actually runs, that the state it assumes holds \u2014 cannot be checked by reading files. The brief hands those to the agent."
4524
+ );
4525
+ disclosures.push("Task findings are not remembered between runs \u2014 no ledger is written.");
4526
+ const ranked = rankFindings(findings);
4527
+ const brief = renderBrief(task, ranked, entities);
4528
+ let briefPath = null;
4529
+ if (opts.writeBrief !== false && await pathExists(path2.join(root, ETYMD_DIR))) {
4530
+ await promises.writeFile(path2.join(root, PREMISE_BRIEF_FILE), brief, "utf8");
4531
+ briefPath = PREMISE_BRIEF_FILE;
4532
+ }
4533
+ return {
4534
+ schema: "premise/1",
4535
+ task,
4536
+ source: opts.source ?? "argument",
4537
+ name: facts.name,
4538
+ findings: ranked,
4539
+ disclosures,
4540
+ entities,
4541
+ brief,
4542
+ briefPath
4543
+ };
4544
+ }
4545
+ function mark(exists) {
4546
+ return exists === null ? "?" : exists ? "\u2713" : "\u2717";
4547
+ }
4548
+ function kindLabel(kind) {
4549
+ return kind === "script" ? "script " : kind === "decision" ? "decision " : "";
4550
+ }
4551
+ function renderBrief(task, findings, entities) {
4552
+ const oneLine = task.replace(/\s+/g, " ").trim();
4553
+ const title = oneLine.length > 100 ? `${oneLine.slice(0, 97)}\u2026` : oneLine;
4554
+ const checked = entities.length ? entities.map(
4555
+ (e) => `- ${mark(e.exists)} ${kindLabel(e.kind)}\`${e.value}\`${e.exists === null ? " \u2014 could not be checked" : e.exists ? " \u2014 exists" : " \u2014 missing (see the finding)"}`
4556
+ ).join("\n") : "- (nothing the task names could be checked by reading the repo)";
4557
+ const failed = findings.filter((f) => f.tier === "risk");
4558
+ const failedLine = failed.length ? `
4559
+ **${failed.length} premise(s) already failed** \u2014 the task names things that do not exist. Resolve those before anything below.
4560
+ ` : "";
4561
+ return `# Premise brief \u2014 ${title}
4562
+
4563
+ The task, verbatim:
4564
+
4565
+ > ${task.replace(/\n/g, "\n> ")}
4566
+
4567
+ etymd checked what the task names against the repo. What it could NOT check is listed under
4568
+ "only you can verify"; check those before acting, and if one fails, say so first \u2014 everything
4569
+ else is downstream of it.
4570
+ ${failedLine}
4571
+ ## What the task names, and whether it exists
4572
+
4573
+ ${checked}
4574
+
4575
+ ## Premises only you can verify
4576
+
4577
+ 1. **The named things are the ones MEANT.** A file that exists can still be the wrong file, and a
4578
+ script that exists can still not be the one the task has in mind. Confirm each is where the
4579
+ behaviour actually lives before touching it.
4580
+ 2. **The mechanism the task assumes is working.** A task shaped like "make X faster / less flaky /
4581
+ fresh after Y" assumes X and Y already do what it says. Run them once. If the assumed
4582
+ mechanism does not run, the task is downstream of a defect it does not mention.
4583
+ 3. **The state the task assumes holds.** Read it; do not trust the task's description of it.
4584
+
4585
+ ## If a premise fails
4586
+
4587
+ Report it first, in one line: what the task presupposes, and what you found instead. Do not
4588
+ answer the original task as if the premise held \u2014 a wrong problem solved precisely is still the
4589
+ wrong problem.
4590
+ `;
4591
+ }
4592
+
4078
4593
  // src/index.ts
4079
4594
  init_version();
4080
4595
  init_version2();
4081
4596
 
4082
- export { CONFIG_FILE, DECISIONS_FORMAT_MARKER, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, FLEET_JSON_SCHEMA, FLEET_LENS, LENSES, NAME, PACK_VERSION, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, cacheFactsPath, checkManifest, collectWallFindings, configPath, contextEconomyLens, corpPersistenceRoot, deriveProfile, expandTilde, extractCommandClaims, extractPathClaims, gateIntegrityLens, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, loadFleetManifest, measureContext, planWorkflow, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, runAudit, scanProject, stateFreshnessLens, sweepFleet, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };
4597
+ export { CONFIG_FILE, DECISIONS_FORMAT_MARKER, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, FLEET_JSON_SCHEMA, FLEET_LENS, LENSES, NAME, PACK_VERSION, PREMISE_BRIEF_FILE, PREMISE_LENS, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, buildTruthEnv, cacheFactsPath, checkDecisionRefs, checkDocRefs, checkManifest, checkTextClaims, collectWallFindings, configPath, contextEconomyLens, corpPersistenceRoot, deriveProfile, emptyCounters, expandTilde, extractCommandClaims, extractPathClaims, gateIntegrityLens, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, loadDecisionLedger, loadFleetManifest, measureContext, planWorkflow, promoteBareTokens, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, renderBrief, runAudit, runPremise, scanProject, stateFreshnessLens, sweepFleet, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };