etymd 0.16.0 → 0.19.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 (30) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +28 -5
  3. package/dist/{approve-K5FFTPA4.js → approve-Y36ZO6IL.js} +3 -3
  4. package/dist/audit-RONSEYFS.js +11 -0
  5. package/dist/{brief-DOQ2DQEG.js → brief-ILB6RMSQ.js} +3 -3
  6. package/dist/{chunk-FKDT4RBP.js → chunk-4IESOWHQ.js} +2 -2
  7. package/dist/{chunk-EJM47PIF.js → chunk-DLHC7QON.js} +83 -10
  8. package/dist/{chunk-Y4E6ERCM.js → chunk-EYUW3UJU.js} +59 -11
  9. package/dist/chunk-EZRK4MV3.js +109 -0
  10. package/dist/{chunk-OKJ3RGJB.js → chunk-HSRHOVII.js} +113 -117
  11. package/dist/{chunk-UV3YIBDW.js → chunk-KIPD3N77.js} +11 -6
  12. package/dist/{chunk-UYCGP5BS.js → chunk-L4KIIKXI.js} +1 -1
  13. package/dist/{chunk-YQZDYDAK.js → chunk-U7BCBJIH.js} +1 -1
  14. package/dist/{chunk-ANXGT7V3.js → chunk-ZCOIZVSM.js} +37 -5
  15. package/dist/cli.js +17 -17
  16. package/dist/{doctor-X6XAGKV7.js → doctor-EHRADIUZ.js} +6 -6
  17. package/dist/{fleet-UEFLKIRI.js → fleet-KFF3N2O7.js} +22 -6
  18. package/dist/{gates-NVWWKMT2.js → gates-UJ23THID.js} +9 -5
  19. package/dist/{generate-YEARCSHW.js → generate-6IPTCLYG.js} +3 -2
  20. package/dist/index.d.ts +29 -3
  21. package/dist/index.js +313 -52
  22. package/dist/{init-Z5O2WVRJ.js → init-7HWVHZMF.js} +5 -4
  23. package/dist/{premise-4QRU7LRI.js → premise-U6JIB2QG.js} +9 -5
  24. package/dist/{propose-IKE4CUIM.js → propose-NZMEPZIL.js} +6 -6
  25. package/dist/{scan-H3NF5IV5.js → scan-DLKYG5BB.js} +3 -3
  26. package/dist/scan-V53PGAQ6.js +5 -0
  27. package/dist/{screen-E4FC7W5N.js → screen-S736DXQ5.js} +56 -24
  28. package/package.json +2 -2
  29. package/dist/audit-HBNXNGD6.js +0 -11
  30. package/dist/scan-64Z6SNYF.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 = "12";
23
+ PACK_VERSION = "14";
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.16.0",
33
+ version: "0.19.0",
34
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",
@@ -104,7 +104,7 @@ var init_package = __esm({
104
104
  prettier: "3.4.2",
105
105
  tsup: "8.5.1",
106
106
  typescript: "5.7.2",
107
- vitest: "4.1.10"
107
+ vitest: "4.1.11"
108
108
  },
109
109
  publishConfig: {
110
110
  access: "public"
@@ -388,10 +388,17 @@ async function hasLintStagedConfig(root, pkg) {
388
388
  }
389
389
  return false;
390
390
  }
391
+ function normalizeHooksPath(root, raw) {
392
+ const abs = path2.resolve(root, raw);
393
+ const rel = path2.relative(root, abs);
394
+ const outside = path2.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path2.sep}`);
395
+ return outside ? abs : normalizeRelPath(rel) || ".";
396
+ }
391
397
  async function detectHooks(root, hooksPath, pkg) {
392
398
  const lintStaged = await hasLintStagedConfig(root, pkg);
393
399
  if (hooksPath) {
394
- if (hooksPath.replace(/\/+$/, "") === ".husky/_") {
400
+ const dir = normalizeHooksPath(root, hooksPath);
401
+ if (dir === ".husky/_") {
395
402
  const base2 = path2.join(root, ".husky");
396
403
  return {
397
404
  source: "husky",
@@ -402,10 +409,10 @@ async function detectHooks(root, hooksPath, pkg) {
402
409
  lintStaged
403
410
  };
404
411
  }
405
- const base = path2.join(root, hooksPath);
412
+ const base = path2.resolve(root, dir);
406
413
  return {
407
- source: hooksPath === ".githooks" ? "githooks" : "custom",
408
- dir: hooksPath,
414
+ source: dir === ".githooks" ? "githooks" : "custom",
415
+ dir,
409
416
  preCommit: await pathExists(path2.join(base, "pre-commit")),
410
417
  prePush: await pathExists(path2.join(base, "pre-push")),
411
418
  commitMsg: await pathExists(path2.join(base, "commit-msg")),
@@ -494,6 +501,64 @@ async function detectArtifacts(root) {
494
501
  });
495
502
  return artifacts;
496
503
  }
504
+ function detectClaudeCodeVersion() {
505
+ const override = process.env.ETYMD_CLAUDE_VERSION;
506
+ if (override !== void 0) {
507
+ return Promise.resolve(override === "none" || override === "" ? null : override);
508
+ }
509
+ claudeVersionMemo ??= pExecFile2("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null).catch(() => null);
510
+ return claudeVersionMemo;
511
+ }
512
+ function versionBefore(a, b) {
513
+ const pa = a.split(".").map(Number);
514
+ const pb = b.split(".").map(Number);
515
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
516
+ const x = pa[i] ?? 0;
517
+ const y = pb[i] ?? 0;
518
+ if (x !== y) return x < y;
519
+ }
520
+ return false;
521
+ }
522
+ async function checkClaudePointer(root, claudeVersion) {
523
+ const agentsAbs = path2.join(root, "AGENTS.md");
524
+ const claudeAbs = path2.join(root, "CLAUDE.md");
525
+ if (!await pathExists(agentsAbs)) return { ok: true, via: "no-agents" };
526
+ const statOrNone = async (p) => {
527
+ try {
528
+ return await promises.stat(p);
529
+ } catch {
530
+ return null;
531
+ }
532
+ };
533
+ const [agentsSt, claudeSt] = await Promise.all([statOrNone(agentsAbs), statOrNone(claudeAbs)]);
534
+ if (agentsSt && claudeSt && agentsSt.ino !== 0 && agentsSt.ino === claudeSt.ino && agentsSt.dev === claudeSt.dev) {
535
+ return { ok: true, via: "same-file" };
536
+ }
537
+ const rootClaude = await readText(claudeAbs);
538
+ if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(rootClaude)) {
539
+ return { ok: true, via: "root-import" };
540
+ }
541
+ const dotClaude = await readText(path2.join(root, ".claude", "CLAUDE.md"));
542
+ if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(dotClaude)) {
543
+ return { ok: true, via: "dotclaude-import" };
544
+ }
545
+ if (rootClaude !== null || dotClaude !== null) {
546
+ return {
547
+ ok: false,
548
+ kind: "no-import",
549
+ detail: rootClaude !== null ? "AGENTS.md and CLAUDE.md both present, but CLAUDE.md does not import it" : "AGENTS.md and .claude/CLAUDE.md both present, but .claude/CLAUDE.md does not import it"
550
+ };
551
+ }
552
+ const version = await detectClaudeCodeVersion() ;
553
+ if (version !== null && versionBefore(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
554
+ return {
555
+ ok: false,
556
+ kind: "old-reader",
557
+ detail: `AGENTS.md present, no CLAUDE.md, and Claude Code ${version} predates the AGENTS.md fallback (${CLAUDE_AGENTS_FALLBACK_VERSION})`
558
+ };
559
+ }
560
+ return { ok: true, via: "native-fallback" };
561
+ }
497
562
  async function walkTree(root) {
498
563
  const state = { budget: 2e4, truncated: false };
499
564
  const countFiles = async (dir) => {
@@ -539,7 +604,7 @@ async function walkTree(root) {
539
604
  dirs.sort((a, b) => b.files - a.files);
540
605
  return { dirs, truncated: state.truncated };
541
606
  }
542
- var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS, SHELL_SHEBANG, SHELL_EXT;
607
+ var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS, SHELL_SHEBANG, SHELL_EXT, ROOT_POINTER_IMPORT_RE, DOT_CLAUDE_IMPORT_RE, CLAUDE_AGENTS_FALLBACK_VERSION, pExecFile2, claudeVersionMemo;
543
608
  var init_detect = __esm({
544
609
  "src/core/detect.ts"() {
545
610
  init_util();
@@ -714,6 +779,10 @@ var init_detect = __esm({
714
779
  ];
715
780
  SHELL_SHEBANG = /^#!\s*\/\S*\b(?:ba|da|z)?sh\b|^#!\s*\/usr\/bin\/env\s+(?:ba|da|z)?sh\b/;
716
781
  SHELL_EXT = /\.(?:sh|bash|zsh)$/;
782
+ ROOT_POINTER_IMPORT_RE = /^\s*@(\.\/)?AGENTS\.md\s*$/m;
783
+ DOT_CLAUDE_IMPORT_RE = /^\s*@\.\.\/AGENTS\.md\s*$/m;
784
+ CLAUDE_AGENTS_FALLBACK_VERSION = "2.1.277";
785
+ pExecFile2 = promisify(execFile);
717
786
  }
718
787
  });
719
788
 
@@ -795,11 +864,12 @@ async function scanProject(root, opts = {}) {
795
864
  const [branch, head, hooksPathRaw, authorsRaw, subjectsRaw] = isRepo ? await Promise.all([
796
865
  git(abs, ["rev-parse", "--abbrev-ref", "HEAD"]),
797
866
  git(abs, ["rev-parse", "--short", "HEAD"]),
798
- git(abs, ["config", "--get", "core.hooksPath"]),
867
+ // `--type=path` expands a leading `~` the way git itself does when it runs the hooks.
868
+ git(abs, ["config", "--type=path", "--get", "core.hooksPath"]),
799
869
  git(abs, ["log", "-200", "--format=%ae"]),
800
870
  git(abs, ["log", "-50", "--format=%s"])
801
871
  ]) : [null, null, null, null, null];
802
- const hooksPath = hooksPathRaw ?? void 0;
872
+ const hooksPath = hooksPathRaw ? normalizeHooksPath(abs, hooksPathRaw) : void 0;
803
873
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
804
874
  const shell = await detectShellSurface(abs, isRepo);
805
875
  const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
@@ -1132,6 +1202,19 @@ ${[
1132
1202
  ].filter(Boolean).join("\n") || "# add the project's key commands"}
1133
1203
  \`\`\`
1134
1204
 
1205
+ `,
1206
+ "md"
1207
+ );
1208
+ }
1209
+ function generateClaudePointerMd() {
1210
+ return stampGenerated(
1211
+ `# CLAUDE.md
1212
+
1213
+ Single source of truth for agent instructions lives in \`AGENTS.md\`. This pointer keeps Claude
1214
+ Code aligned with every other agent \u2014 edit \`AGENTS.md\`, not this file.
1215
+
1216
+ @AGENTS.md
1217
+
1135
1218
  `,
1136
1219
  "md"
1137
1220
  );
@@ -1228,42 +1311,118 @@ ${localHookCall("commit-msg")}
1228
1311
  exit 0
1229
1312
  `);
1230
1313
  }
1314
+ function generateShellDiscoveryScript() {
1315
+ return stampGenerated(`#!/usr/bin/env sh
1316
+ # etymd: shell script discovery for the pre-push shellcheck step. Arguments: the scratch
1317
+ # directory the hook created, then the tracked paths to classify (NUL-delimited on the hook's
1318
+ # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
1319
+ # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
1320
+ #
1321
+ # A path with nothing readable behind it \u2014 a submodule entry, a dangling symlink, a file
1322
+ # deleted from the worktree while still tracked \u2014 cannot lie about its contents, so it is a
1323
+ # disclosed skip, never a block: an absent worktree file is routine dirty state. A regular file
1324
+ # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
1325
+ # fails, naming the path.
1326
+ #
1327
+ # The two \`[ "$?" -eq 1 ]\` guards are the match/error protocol: grep reports "no match" as 1
1328
+ # and a failure as 2 or more, and only the first is a verdict. Dropping the guard would let a
1329
+ # failing matcher pass as "not a shell script" \u2014 the exact silent coverage-shrink the
1330
+ # fail-closed rules exist to prevent. The checker does not associate \`$?\` with the enclosing
1331
+ # if-condition, which is why this shape survives the pass it serves; keep it that way.
1332
+ work=$1
1333
+ shift
1334
+ for file do
1335
+ if [ ! -f "./$file" ]; then
1336
+ printf . >> "$work/skip-count" || exit 1
1337
+ continue
1338
+ fi
1339
+ # 4096 bytes bound the read \u2014 a binary with no newline would otherwise be copied whole
1340
+ # into the scratch on every push. The second head restores line-1-only semantics, so a
1341
+ # shebang embedded on a LATER line of a document cannot match the patterns below.
1342
+ head -c 4096 "./$file" > "$work/head-bytes" || {
1343
+ echo "etymd: cannot read tracked file for shellcheck: $file" >&2
1344
+ exit 1
1345
+ }
1346
+ head -n 1 "$work/head-bytes" > "$work/first-line" || exit 1
1347
+ if grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line"; then
1348
+ printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
1349
+ printf . >> "$work/count" || exit 1
1350
+ else
1351
+ [ "$?" -eq 1 ] || exit 1
1352
+ if grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line"; then
1353
+ printf . >> "$work/zsh-count" || exit 1
1354
+ else
1355
+ [ "$?" -eq 1 ] || exit 1
1356
+ fi
1357
+ fi
1358
+ done
1359
+ `);
1360
+ }
1231
1361
  function shellcheckStep() {
1232
1362
  return `
1233
1363
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
1234
- # script added later is covered without regenerating this hook. zsh is NOT in the checked set:
1364
+ # script added later is covered without regenerating this hook. The classifier is
1365
+ # discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
1366
+ # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
1235
1367
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
1236
1368
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
1237
1369
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
1370
+ # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
1371
+ # the push (coverage would otherwise silently shrink), while a tracked path with nothing readable
1372
+ # behind it \u2014 a submodule entry, a dangling symlink, a file deleted from the worktree while
1373
+ # still tracked \u2014 is counted and said so below as skipped, never a block.
1238
1374
  #
1239
1375
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
1240
1376
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
1241
1377
  # explains why it skips a shell dialect must not break the checker while doing it.
1242
1378
  if command -v shellcheck >/dev/null 2>&1; then
1243
- scripts=$(git ls-files -z \\
1244
- | xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da)?sh( |$)" && echo "{}"' \\
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
1252
- if [ -n "$scripts" ]; then
1253
- echo "\u203A shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
1254
- printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
1255
- echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1379
+ (
1380
+ # POSIX pipelines report only the LAST command's status, so discovery tallies progress in
1381
+ # files \u2014 one dot per decision, counted with wc -c after the pipeline \u2014 rather than
1382
+ # streaming through it: a pipeline that dies halfway cannot then pass as complete coverage,
1383
+ # and the same tallies carry the counts to the reporting below. The classifier writes the
1384
+ # tallies; this half only reads them.
1385
+ # NUL delimiters preserve filenames; positional arguments avoid xargs -I size limits and
1386
+ # interpreting filenames as shell code (never -I{}). The subshell confines cleanup to
1387
+ # this step. The classifier itself is the tracked, shebanged helper beside this hook, so
1388
+ # the discovery it performs finds and checks it too \u2014 the gate covers its own classifier.
1389
+ shellcheck_tmp=$(mktemp -d) || exit 1
1390
+ trap 'rm -rf "$shellcheck_tmp"' 0
1391
+ trap 'exit 1' 1 2 3 15
1392
+ git ls-files -z > "$shellcheck_tmp/tracked" || {
1393
+ echo "etymd: cannot enumerate tracked files for shellcheck" >&2
1256
1394
  exit 1
1257
1395
  }
1258
- # Everything below the blocking bar, shown once the push is already cleared. Never affects
1259
- # the exit code \u2014 advice that can fail a push is not advice.
1260
- advice=$(printf '%s\\n' "$scripts" | xargs shellcheck -S style -f gcc 2>/dev/null \\
1261
- | grep -v ': warning:\\|: error:' || true)
1262
- if [ -n "$advice" ]; then
1263
- echo " \xB7 style/info (not blocking):"
1264
- printf '%s\\n' "$advice" | sed 's/^/ /'
1396
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1397
+ xargs -0 "$(dirname "$0")/discover-shell-scripts.sh" "$shellcheck_tmp" < "$shellcheck_tmp/tracked" || {
1398
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1399
+ exit 1
1400
+ }
1401
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
1402
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1403
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1404
+ if [ "$skip_count" -gt 0 ]; then
1405
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule, dangling symlink, or deleted from the worktree) \u2014 not checked, not failed"
1265
1406
  fi
1266
- fi
1407
+ if [ "$zsh_count" -gt 0 ]; then
1408
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1409
+ fi
1410
+ if [ "$count" -gt 0 ]; then
1411
+ echo "\u203A shellcheck ($((count)) scripts, blocking at severity=warning)"
1412
+ xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" || {
1413
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1414
+ exit 1
1415
+ }
1416
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
1417
+ # the exit code \u2014 advice that can fail a push is not advice.
1418
+ advice=$(xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null \\
1419
+ | grep -v ': warning:\\|: error:' || true)
1420
+ if [ -n "$advice" ]; then
1421
+ echo " \xB7 style/info (not blocking):"
1422
+ printf '%s\\n' "$advice" | sed 's/^/ /'
1423
+ fi
1424
+ fi
1425
+ ) || exit 1
1267
1426
  else
1268
1427
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
1269
1428
  fi`;
@@ -1426,6 +1585,7 @@ async function planWorkflow(root, facts, opts) {
1426
1585
  };
1427
1586
  if (opts.agents) {
1428
1587
  await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
1588
+ await add("CLAUDE.md", generateClaudePointerMd(), "Claude Code pointer to AGENTS.md");
1429
1589
  }
1430
1590
  if (opts.gates) {
1431
1591
  const existingPrePush = await readText(path2.join(root, ".githooks", "pre-push"));
@@ -1449,12 +1609,24 @@ async function planWorkflow(root, facts, opts) {
1449
1609
  commitFormat: opts.gateConfig?.commitFormat,
1450
1610
  allowWriting: opts.gateConfig?.allowWriting ?? []
1451
1611
  };
1612
+ const tier = deriveFailOn(facts, {
1613
+ failOn: gateConfig.failOn,
1614
+ explicit: opts.gateFailOnPinned ?? false
1615
+ });
1452
1616
  await add(
1453
1617
  ".githooks/pre-push",
1454
- generatePrePushHook(facts, gateConfig, selfBuild),
1618
+ generatePrePushHook(facts, { ...gateConfig, failOn: tier.failOn }, selfBuild),
1455
1619
  "Correctness gate (pre-push)",
1456
1620
  true
1457
1621
  );
1622
+ if (facts.shell?.scripts) {
1623
+ await add(
1624
+ ".githooks/discover-shell-scripts.sh",
1625
+ generateShellDiscoveryScript(),
1626
+ "Shell discovery (pre-push shellcheck helper)",
1627
+ true
1628
+ );
1629
+ }
1458
1630
  if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
1459
1631
  await add(
1460
1632
  "scripts/artifact-check.sh",
@@ -1889,7 +2061,7 @@ async function localHookTools(root, facts, scripts) {
1889
2061
  const inertCompanions = [];
1890
2062
  const readHook = async (name) => {
1891
2063
  if (!hooks.dir) return empty;
1892
- const text = await readText(path2.join(root, hooks.dir, name));
2064
+ const text = await readText(path2.resolve(root, hooks.dir, name));
1893
2065
  if (!text) return empty;
1894
2066
  const tools = new Set(matchTools(text, scripts));
1895
2067
  const companion = await companionOf(root, hooks.dir, name, text, scripts);
@@ -2038,7 +2210,7 @@ async function buildGateInventory(root, facts) {
2038
2210
 
2039
2211
  // src/lenses/gate-integrity/screener.ts
2040
2212
  init_util();
2041
- var pExecFile2 = promisify(execFile);
2213
+ var pExecFile3 = promisify(execFile);
2042
2214
  var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
2043
2215
  var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
2044
2216
  var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
@@ -2055,7 +2227,7 @@ async function probeScreener(root, facts) {
2055
2227
  const doors = [];
2056
2228
  let devBuildArm = false;
2057
2229
  for (const name of HOOK_FILES) {
2058
- const text = await readText(path2.join(root, dir, name));
2230
+ const text = await readText(path2.resolve(root, dir, name));
2059
2231
  if (!text || !SCREEN_CALL_RE.test(text)) continue;
2060
2232
  doors.push(`${dir}/${name}`);
2061
2233
  if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
@@ -2075,7 +2247,7 @@ async function probeScreener(root, facts) {
2075
2247
  source = "path";
2076
2248
  }
2077
2249
  try {
2078
- await pExecFile2(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2250
+ await pExecFile3(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2079
2251
  return { present: true, doors, runner, source, answersScreen: true };
2080
2252
  } catch (err) {
2081
2253
  const e = err;
@@ -2753,10 +2925,13 @@ function extractCommandClaims(text) {
2753
2925
  return { scripts, filteredSkipped };
2754
2926
  }
2755
2927
  var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
2928
+ var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
2756
2929
  var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
2757
2930
  ..."ts tsx cts mts js jsx cjs mjs json jsonc json5 md mdx mdc yml yaml toml ini cfg conf env sh bash zsh fish ps1 bat cmd css scss sass less html htm xml svg sql prisma graphql gql proto py rb rs go java kt kts swift c h cc cpp hpp cs php vue svelte astro txt log lock csv tsv png jpg jpeg gif webp ico avif woff woff2 ttf otf wasm map pem key crt tf tfvars example sample local snap ejs hbs pug".split(" ")
2758
2931
  ]);
2759
2932
  var CREATION_CONTEXT_RE = /\b(?:creat(?:e|es|ed|ing)|generat(?:e|es|ed|ing)|scaffold(?:s|ed|ing)?|quarantin(?:e|es|ed|ing)|(?:writ(?:e|es|ten|ing)|output(?:s|ted)?|emit(?:s|ted|ting)?|sav(?:e|es|ed|ing)|mov(?:e|es|ed|ing)|copy|copi(?:es|ed))\s+(?:it\s+|them\s+)?(?:to|into)|new\s+(?:file|directory|folder)|will\s+(?:be\s+)?(?:created|generated|written)|add(?:s|ed|ing)?\s+(?:a|the)\s+new)\b/i;
2933
+ var FETCH_CONTEXT_RE = /\b(?:fetch(?:es|ed|ing)?|pull(?:s|ed|ing)?|clone(?:s|d)?|download(?:s|ed|ing)?)\b/i;
2934
+ var EXTERNAL_URL_RE = /https?:\/\/|\bwww\./i;
2760
2935
  var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
2761
2936
  var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
2762
2937
  function isPlaceholderClaim(token) {
@@ -2790,14 +2965,21 @@ ${line}`;
2790
2965
  }
2791
2966
  function extractPathClaims(text, opts = {}) {
2792
2967
  const prospectiveOnly = /* @__PURE__ */ new Map();
2968
+ const fetchedOnly = /* @__PURE__ */ new Map();
2793
2969
  const namespacedOnly = /* @__PURE__ */ new Map();
2794
2970
  const placeholder = /* @__PURE__ */ new Set();
2971
+ const dirs = /* @__PURE__ */ new Set();
2795
2972
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
2796
2973
  const token = m[1].trim();
2797
2974
  if (token.includes(" ") || token.length > 120) continue;
2798
2975
  if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
2799
2976
  continue;
2800
2977
  if (token.includes("://") || token.startsWith("www.")) continue;
2978
+ if (token.includes("/")) {
2979
+ const firstSegment = token.split("/")[0] ?? token;
2980
+ const hostSuffix = HOSTNAME_RE.exec(firstSegment)?.[1]?.toLowerCase();
2981
+ if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) continue;
2982
+ }
2801
2983
  if (/[*?{}<>|]/.test(token)) continue;
2802
2984
  if (token.includes("@")) continue;
2803
2985
  if (!PATH_TOKEN_RE.test(token)) continue;
@@ -2810,8 +2992,12 @@ function extractPathClaims(text, opts = {}) {
2810
2992
  placeholder.add(claim);
2811
2993
  continue;
2812
2994
  }
2813
- const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
2995
+ if (isDirClaim) dirs.add(claim);
2996
+ const context = claimContext(text, m.index ?? 0);
2997
+ const prospective2 = CREATION_CONTEXT_RE.test(context);
2814
2998
  prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
2999
+ const fetched2 = FETCH_CONTEXT_RE.test(context) && EXTERNAL_URL_RE.test(context);
3000
+ fetchedOnly.set(claim, (fetchedOnly.get(claim) ?? true) && fetched2);
2815
3001
  if (opts.namespaces) {
2816
3002
  const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
2817
3003
  const prefixed = isNamespace(nsLead?.[1]);
@@ -2820,13 +3006,15 @@ function extractPathClaims(text, opts = {}) {
2820
3006
  }
2821
3007
  const paths = [];
2822
3008
  const prospective = [];
3009
+ const fetched = [];
2823
3010
  const namespaced = [];
2824
3011
  for (const [claim, only] of prospectiveOnly) {
2825
- if (only) prospective.push(claim);
3012
+ if (fetchedOnly.get(claim)) fetched.push(claim);
3013
+ else if (only) prospective.push(claim);
2826
3014
  else if (namespacedOnly.get(claim)) namespaced.push(claim);
2827
3015
  else paths.push(claim);
2828
3016
  }
2829
- return { paths, prospective, namespaced, placeholder: [...placeholder] };
3017
+ return { paths, dirs: [...dirs], prospective, fetched, namespaced, placeholder: [...placeholder] };
2830
3018
  }
2831
3019
  var LOCAL_REF_LEADINS = new Set(
2832
3020
  "decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
@@ -2898,12 +3086,14 @@ async function buildTruthEnv(root, facts) {
2898
3086
  for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
2899
3087
  }
2900
3088
  const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2901
- const pathResolves = async (claim) => {
3089
+ const pathResolves = async (claim, fromDir) => {
2902
3090
  for (const base of bases) {
2903
3091
  if (await pathExists(path2.join(base, claim))) return true;
2904
3092
  if (await pathExists(path2.join(base, "src", claim))) return true;
2905
3093
  if (await pathExists(path2.join(base, "scripts", claim))) return true;
2906
3094
  }
3095
+ if (fromDir && fromDir !== "." && await pathExists(path2.join(root, fromDir, claim)))
3096
+ return true;
2907
3097
  return false;
2908
3098
  };
2909
3099
  const binResolves = async (name) => {
@@ -2933,6 +3123,7 @@ function emptyCounters() {
2933
3123
  unverifiableCommands: 0,
2934
3124
  gitignoredSkipped: 0,
2935
3125
  prospectiveSkipped: 0,
3126
+ fetchedSkipped: 0,
2936
3127
  placeholderSkipped: 0,
2937
3128
  qualifiedRefsSkipped: 0,
2938
3129
  unresolvableRefs: 0,
@@ -2975,19 +3166,48 @@ async function checkTextClaims(env, file, opts, counters) {
2975
3166
  confidence: "high"
2976
3167
  });
2977
3168
  }
2978
- const { paths, prospective, placeholder, namespaced } = extractPathClaims(file.text, {
2979
- namespaces: opts.treatNamespacedPrefixes
2980
- });
3169
+ const { paths, dirs, prospective, fetched, placeholder, namespaced } = extractPathClaims(
3170
+ file.text,
3171
+ { namespaces: opts.treatNamespacedPrefixes }
3172
+ );
2981
3173
  counters.prospectiveSkipped += prospective.length;
3174
+ counters.fetchedSkipped += fetched.length;
2982
3175
  counters.placeholderSkipped += placeholder.length;
2983
3176
  counters.namespacedSkipped += namespaced.length;
2984
- const missing = [];
3177
+ const fromDir = path2.dirname(file.path);
3178
+ const unresolved = [];
2985
3179
  for (const claim of paths) {
2986
- if (await env.pathResolves(claim)) examined.push({ kind: "path", value: claim, exists: true });
3180
+ if (await env.pathResolves(claim, fromDir))
3181
+ examined.push({ kind: "path", value: claim, exists: true });
3182
+ else unresolved.push(claim);
3183
+ }
3184
+ const proseBases = /* @__PURE__ */ new Set();
3185
+ for (const dirClaim of dirs) {
3186
+ if (await env.pathResolves(dirClaim, fromDir)) {
3187
+ proseBases.add(dirClaim);
3188
+ proseBases.add(path2.dirname(dirClaim));
3189
+ }
3190
+ }
3191
+ const missing = [];
3192
+ for (const claim of unresolved) {
3193
+ let anchored = false;
3194
+ for (const base of proseBases) {
3195
+ if (await pathExists(path2.join(env.root, base, claim))) anchored = true;
3196
+ else if (fromDir !== "." && await pathExists(path2.join(env.root, fromDir, base, claim)))
3197
+ anchored = true;
3198
+ if (anchored) break;
3199
+ }
3200
+ if (anchored) examined.push({ kind: "path", value: claim, exists: true });
2987
3201
  else missing.push(claim);
2988
3202
  }
2989
3203
  const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
2990
3204
  const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
3205
+ const unproven = missing.filter((claim) => !gitignored.has(claim));
3206
+ if (unproven.length) {
3207
+ const dirOut = await git(env.root, ["check-ignore", ...unproven.map((claim) => `${claim}/`)]);
3208
+ for (const line of (dirOut ?? "").split("\n").filter(Boolean))
3209
+ gitignored.add(line.replace(/\/$/, ""));
3210
+ }
2991
3211
  let pathFindings = 0;
2992
3212
  for (const claim of missing) {
2993
3213
  if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
@@ -3275,6 +3495,11 @@ var instructionTruthLens = {
3275
3495
  `${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
3276
3496
  );
3277
3497
  }
3498
+ if (counters.fetchedSkipped) {
3499
+ disclosures.push(
3500
+ `${counters.fetchedSkipped} path claim(s) sit beside the URL they are fetched from \u2014 another tree's files, unverifiable here; skipped, not flagged.`
3501
+ );
3502
+ }
3278
3503
  if (counters.placeholderSkipped) {
3279
3504
  disclosures.push(
3280
3505
  `${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
@@ -3312,7 +3537,7 @@ var instructionTruthLens = {
3312
3537
  );
3313
3538
  }
3314
3539
  disclosures.push(
3315
- `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.`
3540
+ `Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root, package roots, the claiming file's directory, and directories the same file names; existence judged on the working tree (gitignored-but-present is true, gitignored-and-absent unverifiable). Heuristics: workspace-filtered commands skipped (${counters.filteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); schemeless hosts read as URLs; gitignored claims unverifiable; create-this, fetched-from-URL, and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
3316
3541
  );
3317
3542
  return {
3318
3543
  lens: LENS_ID4,
@@ -3743,6 +3968,7 @@ async function loadFleetManifest(manifestPath) {
3743
3968
 
3744
3969
  // src/engine/fleet.ts
3745
3970
  init_config();
3971
+ init_detect();
3746
3972
  init_facts();
3747
3973
  init_util();
3748
3974
 
@@ -3871,7 +4097,7 @@ function parseMilestones(text) {
3871
4097
  }
3872
4098
 
3873
4099
  // src/engine/fleet.ts
3874
- var pExecFile3 = promisify(execFile);
4100
+ var pExecFile4 = promisify(execFile);
3875
4101
  var FLEET_LENS = "fleet-manifest";
3876
4102
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
3877
4103
  var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
@@ -3926,7 +4152,7 @@ function stateBudgetsFor(entry) {
3926
4152
  async function gitGrepFiles(root, needle, mode = "fixed") {
3927
4153
  try {
3928
4154
  const matchFlag = mode === "regex" ? "-E" : "-F";
3929
- const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
4155
+ const { stdout } = await pExecFile4("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3930
4156
  cwd: root,
3931
4157
  timeout: 8e3
3932
4158
  });
@@ -4307,8 +4533,13 @@ async function checkGateDrift(manifest, findings, disclosures) {
4307
4533
  );
4308
4534
  continue;
4309
4535
  }
4310
- const { config } = await readConfig2(root);
4311
- const planned = (await planWorkflow2(root, facts, { agents: false, gates: true, gateConfig: config.gates })).filter((f) => f.executable);
4536
+ const { config, explicit } = await readConfig2(root);
4537
+ const planned = (await planWorkflow2(root, facts, {
4538
+ agents: false,
4539
+ gates: true,
4540
+ gateConfig: config.gates,
4541
+ gateFailOnPinned: explicit.gatesFailOn
4542
+ })).filter((f) => f.executable);
4312
4543
  const missing = planned.filter((f) => !f.exists).map((f) => f.path);
4313
4544
  const differing = planned.filter((f) => f.exists && f.differs);
4314
4545
  const stale = differing.filter((f) => f.drift !== "edited").map((f) => f.path);
@@ -4344,6 +4575,31 @@ async function checkGateDrift(manifest, findings, disclosures) {
4344
4575
  }
4345
4576
  }
4346
4577
  }
4578
+ async function checkClaudePointers(manifest, findings) {
4579
+ for (const entry of manifest.entries) {
4580
+ const root = entry.resolvedRoot;
4581
+ if (!root || !await isDirectory(root)) continue;
4582
+ const check = await checkClaudePointer(root);
4583
+ if (check.ok) continue;
4584
+ findings.push(
4585
+ check.kind === "no-import" ? finding3(
4586
+ `${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
4587
+ "risk",
4588
+ `\`${entry.name}\` has a CLAUDE.md that never imports its AGENTS.md`,
4589
+ [`${entry.name}: ${check.detail}`],
4590
+ "Claude Code reads CLAUDE.md when one exists and falls back to AGENTS.md only when none does. A CLAUDE.md without the import shadows AGENTS.md, so the contract looks universal from inside the repo while Claude Code never receives it.",
4591
+ "Add a full-line `@AGENTS.md` import to that CLAUDE.md, symlink either file to the other, or delete the CLAUDE.md if AGENTS.md is the whole contract."
4592
+ ) : finding3(
4593
+ `${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
4594
+ "gap",
4595
+ `\`${entry.name}\` relies on the AGENTS.md fallback, which the installed Claude Code predates`,
4596
+ [`${entry.name}: ${check.detail}`],
4597
+ `Claude Code reads AGENTS.md on its own only from ${CLAUDE_AGENTS_FALLBACK_VERSION}; older releases load CLAUDE.md alone, so this repo's contract is invisible to them.`,
4598
+ "Update Claude Code, or create a CLAUDE.md beside AGENTS.md whose only import line is `@AGENTS.md`."
4599
+ )
4600
+ );
4601
+ }
4602
+ }
4347
4603
  async function collectWallFindings(manifest) {
4348
4604
  const findings = [];
4349
4605
  const disclosures = [];
@@ -4353,6 +4609,7 @@ async function collectWallFindings(manifest) {
4353
4609
  await checkHygieneNeedles(manifest, findings, disclosures);
4354
4610
  await checkGuardedEmails(manifest, findings, disclosures);
4355
4611
  await checkGateDrift(manifest, findings, disclosures);
4612
+ await checkClaudePointers(manifest, findings);
4356
4613
  return { findings, disclosures };
4357
4614
  }
4358
4615
  function stateAgeDays(facts) {
@@ -4699,7 +4956,6 @@ var STOP_WORDS = new Set(
4699
4956
  );
4700
4957
  var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
4701
4958
  var TRAIL_RE = /[.,;:!?)\]}"']*$/;
4702
- var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
4703
4959
  function promoteBareTokens(text, ctx) {
4704
4960
  const skips = {
4705
4961
  bareInvocations: 0,
@@ -4887,6 +5143,11 @@ async function runPremise(opts) {
4887
5143
  `${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
4888
5144
  );
4889
5145
  }
5146
+ if (counters.fetchedSkipped) {
5147
+ disclosures.push(
5148
+ `${counters.fetchedSkipped} path(s) are named beside the URL they are fetched from \u2014 another tree, not this repo; skipped, not flagged.`
5149
+ );
5150
+ }
4890
5151
  if (counters.placeholderSkipped) {
4891
5152
  disclosures.push(
4892
5153
  `${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`