etymd 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +10 -5
  3. package/dist/{approve-VN4EDBR5.js → approve-J6JU7MBX.js} +3 -3
  4. package/dist/audit-5N2TU2X7.js +11 -0
  5. package/dist/{brief-7MWP35UI.js → brief-NHHILXIH.js} +3 -3
  6. package/dist/{chunk-YQZDYDAK.js → chunk-2DRA7S67.js} +1 -1
  7. package/dist/{chunk-446EZNAQ.js → chunk-6DJMKZGK.js} +83 -10
  8. package/dist/chunk-7N5JDXQG.js +109 -0
  9. package/dist/{chunk-ANFNXFRI.js → chunk-GFUDKTC5.js} +2 -2
  10. package/dist/{chunk-BUMOGHQA.js → chunk-KQFEKOON.js} +1 -1
  11. package/dist/{chunk-LVR2DPR7.js → chunk-M7XDP6FT.js} +30 -3
  12. package/dist/{chunk-PIJZUDDQ.js → chunk-PQISPOB2.js} +191 -130
  13. package/dist/{chunk-WKP7M2B3.js → chunk-UMKC3OWQ.js} +11 -6
  14. package/dist/{chunk-FNA4R5KT.js → chunk-WKP72Z3J.js} +59 -11
  15. package/dist/cli.js +16 -16
  16. package/dist/{doctor-R4QDCAN5.js → doctor-264Q5PLY.js} +6 -6
  17. package/dist/{fleet-WPYEVHVL.js → fleet-VT625Q2J.js} +22 -6
  18. package/dist/{gates-3HRGYCYY.js → gates-YUMSTKUG.js} +5 -4
  19. package/dist/{generate-FSJQFQPV.js → generate-62GQSCKH.js} +3 -2
  20. package/dist/index.d.ts +21 -3
  21. package/dist/index.js +379 -58
  22. package/dist/{init-6RULX5JZ.js → init-G4XXFXVD.js} +5 -4
  23. package/dist/{premise-GFWHSR3K.js → premise-RNMAKMVA.js} +9 -5
  24. package/dist/{propose-IYNOIWAO.js → propose-35JXMEPH.js} +6 -6
  25. package/dist/scan-F6OW4ANK.js +5 -0
  26. package/dist/{scan-LLWW4YOU.js → scan-PJH32H7C.js} +3 -3
  27. package/package.json +2 -2
  28. package/dist/audit-PHAK5HEW.js +0 -11
  29. package/dist/scan-5SC47FSM.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 = "15";
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.17.0",
33
+ version: "0.19.1",
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
  );
@@ -1154,15 +1237,21 @@ function contentScreenCall(opts) {
1154
1237
  `${indent}fi`
1155
1238
  ].join("\n");
1156
1239
  }
1157
- function localHookCall(hook) {
1240
+ function localHookCall(hook, feedRefs = false) {
1241
+ const refsCapture = feedRefs ? `# git hands the pushed refs to pre-push ONCE, on stdin \u2014 one line per ref:
1242
+ # "<local ref> <local sha> <remote ref> <remote sha>". The shell gate below reads them too, so
1243
+ # they are captured here and fed to each.
1244
+ refs=$(cat)
1245
+ ` : "";
1246
+ const call = feedRefs ? `printf '%s\\n' "$refs" | "$LOCAL" "$@" || exit 1` : `"$LOCAL" "$@" || exit 1`;
1158
1247
  return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
1159
1248
  # is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
1160
1249
  # A guard running tests that build fixture repositories should scrub git's exported GIT_* names
1161
1250
  # first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
1162
1251
  # env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
1163
1252
  LOCAL="$(dirname "$0")/${hook}.local"
1164
- if [ -x "$LOCAL" ]; then
1165
- "$LOCAL" "$@" || exit 1
1253
+ ${refsCapture}if [ -x "$LOCAL" ]; then
1254
+ ${call}
1166
1255
  fi`;
1167
1256
  }
1168
1257
  function generatePreCommitHook(selfBuild = false) {
@@ -1171,10 +1260,10 @@ function generatePreCommitHook(selfBuild = false) {
1171
1260
 
1172
1261
  ${localHookCall("pre-commit")}
1173
1262
 
1174
- # Content screen \u2014 staged file bytes. Refuses to commit environment, guarded-side or identity
1175
- # detail into a repo whose history is (or could become) public. The checker and its patterns
1176
- # are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
1177
- # commit anywhere, active only where you opted in.
1263
+ # Content screen \u2014 staged file bytes. Refuses to commit detail about your environment, work
1264
+ # or identity into a repo whose history is (or could become) public. The checker and its
1265
+ # patterns are machine-local by design, so this is a NO-OP wherever no checker is installed:
1266
+ # safe to commit anywhere, active only where you opted in.
1178
1267
  #
1179
1268
  # Bypass, with a reason: git commit --no-verify
1180
1269
  ${contentGateResolution(selfBuild)}
@@ -1228,42 +1317,181 @@ ${localHookCall("commit-msg")}
1228
1317
  exit 0
1229
1318
  `);
1230
1319
  }
1320
+ function generateShellDiscoveryScript() {
1321
+ return stampGenerated(`#!/usr/bin/env sh
1322
+ # etymd: shell script discovery for the pre-push shellcheck step. Arguments: the scratch
1323
+ # directory the hook created, then the tracked paths to classify (NUL-delimited on the hook's
1324
+ # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
1325
+ # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
1326
+ #
1327
+ # The hook runs it from inside a commit materialised from git's object store, so paths resolve
1328
+ # against that tree, never the working tree. A path with nothing readable behind it \u2014 a
1329
+ # submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
1330
+ # skip, never a block. A regular file
1331
+ # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
1332
+ # fails, naming the path.
1333
+ #
1334
+ # The two \`[ "$?" -eq 1 ]\` guards are the match/error protocol: grep reports "no match" as 1
1335
+ # and a failure as 2 or more, and only the first is a verdict. Dropping the guard would let a
1336
+ # failing matcher pass as "not a shell script" \u2014 the exact silent coverage-shrink the
1337
+ # fail-closed rules exist to prevent. The checker does not associate \`$?\` with the enclosing
1338
+ # if-condition, which is why this shape survives the pass it serves; keep it that way.
1339
+ work=$1
1340
+ shift
1341
+ for file do
1342
+ if [ ! -f "./$file" ]; then
1343
+ printf . >> "$work/skip-count" || exit 1
1344
+ continue
1345
+ fi
1346
+ # 4096 bytes bound the read \u2014 a binary with no newline would otherwise be copied whole
1347
+ # into the scratch on every push. The second head restores line-1-only semantics, so a
1348
+ # shebang embedded on a LATER line of a document cannot match the patterns below.
1349
+ head -c 4096 "./$file" > "$work/head-bytes" || {
1350
+ echo "etymd: cannot read tracked file for shellcheck: $file" >&2
1351
+ exit 1
1352
+ }
1353
+ head -n 1 "$work/head-bytes" > "$work/first-line" || exit 1
1354
+ if grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line"; then
1355
+ printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
1356
+ printf . >> "$work/count" || exit 1
1357
+ else
1358
+ [ "$?" -eq 1 ] || exit 1
1359
+ if grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line"; then
1360
+ printf . >> "$work/zsh-count" || exit 1
1361
+ else
1362
+ [ "$?" -eq 1 ] || exit 1
1363
+ fi
1364
+ fi
1365
+ done
1366
+ `);
1367
+ }
1231
1368
  function shellcheckStep() {
1232
1369
  return `
1233
1370
  # 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:
1371
+ # script added later is covered without regenerating this hook. The commits BEING PUSHED are
1372
+ # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
1373
+ # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
1374
+ # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
1375
+ # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
1376
+ # is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
1377
+ # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
1235
1378
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
1236
1379
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
1237
1380
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
1381
+ # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
1382
+ # the push (coverage would otherwise silently shrink), while a tracked path with nothing readable
1383
+ # behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
1384
+ # never a block.
1238
1385
  #
1239
1386
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
1240
1387
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
1241
1388
  # explains why it skips a shell dialect must not break the checker while doing it.
1242
1389
  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'"
1390
+ (
1391
+ # POSIX pipelines report only the LAST command's status, so discovery tallies progress in
1392
+ # files \u2014 one dot per decision, counted with wc -c after the pipeline \u2014 rather than
1393
+ # streaming through it: a pipeline that dies halfway cannot then pass as complete coverage,
1394
+ # and the same tallies carry the counts to the reporting below. The classifier writes the
1395
+ # tallies; this half only reads them.
1396
+ # NUL delimiters preserve filenames; positional arguments avoid xargs -I size limits and
1397
+ # interpreting filenames as shell code (never -I{}). The subshell confines cleanup to
1398
+ # this step. The classifier itself is the tracked, shebanged helper beside this hook, so
1399
+ # the discovery it performs finds and checks it too \u2014 the gate covers its own classifier.
1400
+ shellcheck_tmp=$(mktemp -d) || exit 1
1401
+ trap 'rm -rf "$shellcheck_tmp"' 0
1402
+ trap 'exit 1' 1 2 3 15
1403
+ # Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
1404
+ # script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
1405
+ # An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
1406
+ # branch: everything no remote already has is being pushed, so the range is the local sha
1407
+ # minus every remote-tracking ref \u2014 commits a remote already received were gated when they
1408
+ # landed there, and the residue is exactly this push's new commits. A remote sha this clone
1409
+ # has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
1410
+ # the same new-branch rule instead of refusing a push the gate could have read. Enumeration
1411
+ # failure refuses the push: a range the gate could not list is a range it did not read.
1412
+ # (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
1413
+ # in a case pattern.
1414
+ : > "$shellcheck_tmp/shas" || exit 1
1415
+ printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
1416
+ case "$lsha" in
1417
+ (*[!0]*) ;;
1418
+ (*) continue ;;
1419
+ esac
1420
+ case "$rsha" in
1421
+ (*[!0]*)
1422
+ if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
1423
+ git rev-list "$rsha..$lsha"
1424
+ else
1425
+ git rev-list "$lsha" --not --remotes
1426
+ fi ;;
1427
+ (*) git rev-list "$lsha" --not --remotes ;;
1428
+ esac >> "$shellcheck_tmp/shas" || exit 1
1429
+ done || {
1430
+ echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
1256
1431
  exit 1
1257
1432
  }
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/^/ /'
1265
- fi
1266
- fi
1433
+ shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
1434
+ [ -n "$shas" ] || echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
1435
+ # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
1436
+ # relative hook path would no longer point at it from there.
1437
+ discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
1438
+ for sha in $shas; do
1439
+ # A fresh directory per commit, removed once that commit is checked so a long push does
1440
+ # not pile up one full tree per commit; the subshell trap above still cleans the root on
1441
+ # every early exit.
1442
+ tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
1443
+ # Checked out through a scratch index, never \`git archive\`: archive honours the
1444
+ # export-ignore and export-subst attributes, so a script marked export-ignore would leave
1445
+ # the checked set without a word. read-tree + checkout-index writes every tracked blob.
1446
+ # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
1447
+ # network to be checked.
1448
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
1449
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
1450
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
1451
+ git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
1452
+ echo "\u2717 shellcheck: could not materialise $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") \u2014 refusing the push rather than certifying bytes this gate did not read" >&2
1453
+ exit 1
1454
+ fi
1455
+ if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
1456
+ echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
1457
+ exit 1
1458
+ fi
1459
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1460
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
1461
+ echo "etymd: cannot enumerate the tree of $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") for shellcheck" >&2
1462
+ exit 1
1463
+ }
1464
+ ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
1465
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1466
+ exit 1
1467
+ }
1468
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
1469
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1470
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1471
+ if [ "$skip_count" -gt 0 ]; then
1472
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
1473
+ fi
1474
+ if [ "$zsh_count" -gt 0 ]; then
1475
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1476
+ fi
1477
+ if [ "$count" -gt 0 ]; then
1478
+ echo "\u203A shellcheck ($((count)) scripts in $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha"), blocking at severity=warning)"
1479
+ ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
1480
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1481
+ exit 1
1482
+ }
1483
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
1484
+ # the exit code \u2014 advice that can fail a push is not advice.
1485
+ advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
1486
+ | grep -v ': warning:\\|: error:' || true)
1487
+ if [ -n "$advice" ]; then
1488
+ echo " \xB7 style/info (not blocking):"
1489
+ printf '%s\\n' "$advice" | sed 's/^/ /'
1490
+ fi
1491
+ fi
1492
+ rm -rf "$tree" || exit 1
1493
+ done
1494
+ ) || exit 1
1267
1495
  else
1268
1496
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
1269
1497
  fi`;
@@ -1296,7 +1524,7 @@ fi`;
1296
1524
  return stampGenerated(`#!/usr/bin/env sh
1297
1525
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
1298
1526
 
1299
- ${localHookCall("pre-push")}${runner}
1527
+ ${localHookCall("pre-push", true)}${runner}
1300
1528
  ${body}${shellStep}
1301
1529
  ${auditStep}
1302
1530
 
@@ -1426,6 +1654,7 @@ async function planWorkflow(root, facts, opts) {
1426
1654
  };
1427
1655
  if (opts.agents) {
1428
1656
  await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
1657
+ await add("CLAUDE.md", generateClaudePointerMd(), "Claude Code pointer to AGENTS.md");
1429
1658
  }
1430
1659
  if (opts.gates) {
1431
1660
  const existingPrePush = await readText(path2.join(root, ".githooks", "pre-push"));
@@ -1459,6 +1688,14 @@ async function planWorkflow(root, facts, opts) {
1459
1688
  "Correctness gate (pre-push)",
1460
1689
  true
1461
1690
  );
1691
+ if (facts.shell?.scripts) {
1692
+ await add(
1693
+ ".githooks/discover-shell-scripts.sh",
1694
+ generateShellDiscoveryScript(),
1695
+ "Shell discovery (pre-push shellcheck helper)",
1696
+ true
1697
+ );
1698
+ }
1462
1699
  if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
1463
1700
  await add(
1464
1701
  "scripts/artifact-check.sh",
@@ -1893,7 +2130,7 @@ async function localHookTools(root, facts, scripts) {
1893
2130
  const inertCompanions = [];
1894
2131
  const readHook = async (name) => {
1895
2132
  if (!hooks.dir) return empty;
1896
- const text = await readText(path2.join(root, hooks.dir, name));
2133
+ const text = await readText(path2.resolve(root, hooks.dir, name));
1897
2134
  if (!text) return empty;
1898
2135
  const tools = new Set(matchTools(text, scripts));
1899
2136
  const companion = await companionOf(root, hooks.dir, name, text, scripts);
@@ -2042,7 +2279,7 @@ async function buildGateInventory(root, facts) {
2042
2279
 
2043
2280
  // src/lenses/gate-integrity/screener.ts
2044
2281
  init_util();
2045
- var pExecFile2 = promisify(execFile);
2282
+ var pExecFile3 = promisify(execFile);
2046
2283
  var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
2047
2284
  var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
2048
2285
  var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
@@ -2059,7 +2296,7 @@ async function probeScreener(root, facts) {
2059
2296
  const doors = [];
2060
2297
  let devBuildArm = false;
2061
2298
  for (const name of HOOK_FILES) {
2062
- const text = await readText(path2.join(root, dir, name));
2299
+ const text = await readText(path2.resolve(root, dir, name));
2063
2300
  if (!text || !SCREEN_CALL_RE.test(text)) continue;
2064
2301
  doors.push(`${dir}/${name}`);
2065
2302
  if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
@@ -2079,7 +2316,7 @@ async function probeScreener(root, facts) {
2079
2316
  source = "path";
2080
2317
  }
2081
2318
  try {
2082
- await pExecFile2(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2319
+ await pExecFile3(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2083
2320
  return { present: true, doors, runner, source, answersScreen: true };
2084
2321
  } catch (err) {
2085
2322
  const e = err;
@@ -2757,10 +2994,13 @@ function extractCommandClaims(text) {
2757
2994
  return { scripts, filteredSkipped };
2758
2995
  }
2759
2996
  var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
2997
+ var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
2760
2998
  var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
2761
2999
  ..."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(" ")
2762
3000
  ]);
2763
3001
  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;
3002
+ var FETCH_CONTEXT_RE = /\b(?:fetch(?:es|ed|ing)?|pull(?:s|ed|ing)?|clone(?:s|d)?|download(?:s|ed|ing)?)\b/i;
3003
+ var EXTERNAL_URL_RE = /https?:\/\/|\bwww\./i;
2764
3004
  var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
2765
3005
  var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
2766
3006
  function isPlaceholderClaim(token) {
@@ -2794,14 +3034,21 @@ ${line}`;
2794
3034
  }
2795
3035
  function extractPathClaims(text, opts = {}) {
2796
3036
  const prospectiveOnly = /* @__PURE__ */ new Map();
3037
+ const fetchedOnly = /* @__PURE__ */ new Map();
2797
3038
  const namespacedOnly = /* @__PURE__ */ new Map();
2798
3039
  const placeholder = /* @__PURE__ */ new Set();
3040
+ const dirs = /* @__PURE__ */ new Set();
2799
3041
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
2800
3042
  const token = m[1].trim();
2801
3043
  if (token.includes(" ") || token.length > 120) continue;
2802
3044
  if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
2803
3045
  continue;
2804
3046
  if (token.includes("://") || token.startsWith("www.")) continue;
3047
+ if (token.includes("/")) {
3048
+ const firstSegment = token.split("/")[0] ?? token;
3049
+ const hostSuffix = HOSTNAME_RE.exec(firstSegment)?.[1]?.toLowerCase();
3050
+ if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) continue;
3051
+ }
2805
3052
  if (/[*?{}<>|]/.test(token)) continue;
2806
3053
  if (token.includes("@")) continue;
2807
3054
  if (!PATH_TOKEN_RE.test(token)) continue;
@@ -2814,8 +3061,12 @@ function extractPathClaims(text, opts = {}) {
2814
3061
  placeholder.add(claim);
2815
3062
  continue;
2816
3063
  }
2817
- const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
3064
+ if (isDirClaim) dirs.add(claim);
3065
+ const context = claimContext(text, m.index ?? 0);
3066
+ const prospective2 = CREATION_CONTEXT_RE.test(context);
2818
3067
  prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
3068
+ const fetched2 = FETCH_CONTEXT_RE.test(context) && EXTERNAL_URL_RE.test(context);
3069
+ fetchedOnly.set(claim, (fetchedOnly.get(claim) ?? true) && fetched2);
2819
3070
  if (opts.namespaces) {
2820
3071
  const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
2821
3072
  const prefixed = isNamespace(nsLead?.[1]);
@@ -2824,13 +3075,15 @@ function extractPathClaims(text, opts = {}) {
2824
3075
  }
2825
3076
  const paths = [];
2826
3077
  const prospective = [];
3078
+ const fetched = [];
2827
3079
  const namespaced = [];
2828
3080
  for (const [claim, only] of prospectiveOnly) {
2829
- if (only) prospective.push(claim);
3081
+ if (fetchedOnly.get(claim)) fetched.push(claim);
3082
+ else if (only) prospective.push(claim);
2830
3083
  else if (namespacedOnly.get(claim)) namespaced.push(claim);
2831
3084
  else paths.push(claim);
2832
3085
  }
2833
- return { paths, prospective, namespaced, placeholder: [...placeholder] };
3086
+ return { paths, dirs: [...dirs], prospective, fetched, namespaced, placeholder: [...placeholder] };
2834
3087
  }
2835
3088
  var LOCAL_REF_LEADINS = new Set(
2836
3089
  "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(" ")
@@ -2902,12 +3155,14 @@ async function buildTruthEnv(root, facts) {
2902
3155
  for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
2903
3156
  }
2904
3157
  const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2905
- const pathResolves = async (claim) => {
3158
+ const pathResolves = async (claim, fromDir) => {
2906
3159
  for (const base of bases) {
2907
3160
  if (await pathExists(path2.join(base, claim))) return true;
2908
3161
  if (await pathExists(path2.join(base, "src", claim))) return true;
2909
3162
  if (await pathExists(path2.join(base, "scripts", claim))) return true;
2910
3163
  }
3164
+ if (fromDir && fromDir !== "." && await pathExists(path2.join(root, fromDir, claim)))
3165
+ return true;
2911
3166
  return false;
2912
3167
  };
2913
3168
  const binResolves = async (name) => {
@@ -2937,6 +3192,7 @@ function emptyCounters() {
2937
3192
  unverifiableCommands: 0,
2938
3193
  gitignoredSkipped: 0,
2939
3194
  prospectiveSkipped: 0,
3195
+ fetchedSkipped: 0,
2940
3196
  placeholderSkipped: 0,
2941
3197
  qualifiedRefsSkipped: 0,
2942
3198
  unresolvableRefs: 0,
@@ -2979,19 +3235,48 @@ async function checkTextClaims(env, file, opts, counters) {
2979
3235
  confidence: "high"
2980
3236
  });
2981
3237
  }
2982
- const { paths, prospective, placeholder, namespaced } = extractPathClaims(file.text, {
2983
- namespaces: opts.treatNamespacedPrefixes
2984
- });
3238
+ const { paths, dirs, prospective, fetched, placeholder, namespaced } = extractPathClaims(
3239
+ file.text,
3240
+ { namespaces: opts.treatNamespacedPrefixes }
3241
+ );
2985
3242
  counters.prospectiveSkipped += prospective.length;
3243
+ counters.fetchedSkipped += fetched.length;
2986
3244
  counters.placeholderSkipped += placeholder.length;
2987
3245
  counters.namespacedSkipped += namespaced.length;
2988
- const missing = [];
3246
+ const fromDir = path2.dirname(file.path);
3247
+ const unresolved = [];
2989
3248
  for (const claim of paths) {
2990
- if (await env.pathResolves(claim)) examined.push({ kind: "path", value: claim, exists: true });
3249
+ if (await env.pathResolves(claim, fromDir))
3250
+ examined.push({ kind: "path", value: claim, exists: true });
3251
+ else unresolved.push(claim);
3252
+ }
3253
+ const proseBases = /* @__PURE__ */ new Set();
3254
+ for (const dirClaim of dirs) {
3255
+ if (await env.pathResolves(dirClaim, fromDir)) {
3256
+ proseBases.add(dirClaim);
3257
+ proseBases.add(path2.dirname(dirClaim));
3258
+ }
3259
+ }
3260
+ const missing = [];
3261
+ for (const claim of unresolved) {
3262
+ let anchored = false;
3263
+ for (const base of proseBases) {
3264
+ if (await pathExists(path2.join(env.root, base, claim))) anchored = true;
3265
+ else if (fromDir !== "." && await pathExists(path2.join(env.root, fromDir, base, claim)))
3266
+ anchored = true;
3267
+ if (anchored) break;
3268
+ }
3269
+ if (anchored) examined.push({ kind: "path", value: claim, exists: true });
2991
3270
  else missing.push(claim);
2992
3271
  }
2993
3272
  const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
2994
3273
  const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
3274
+ const unproven = missing.filter((claim) => !gitignored.has(claim));
3275
+ if (unproven.length) {
3276
+ const dirOut = await git(env.root, ["check-ignore", ...unproven.map((claim) => `${claim}/`)]);
3277
+ for (const line of (dirOut ?? "").split("\n").filter(Boolean))
3278
+ gitignored.add(line.replace(/\/$/, ""));
3279
+ }
2995
3280
  let pathFindings = 0;
2996
3281
  for (const claim of missing) {
2997
3282
  if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
@@ -3279,6 +3564,11 @@ var instructionTruthLens = {
3279
3564
  `${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
3280
3565
  );
3281
3566
  }
3567
+ if (counters.fetchedSkipped) {
3568
+ disclosures.push(
3569
+ `${counters.fetchedSkipped} path claim(s) sit beside the URL they are fetched from \u2014 another tree's files, unverifiable here; skipped, not flagged.`
3570
+ );
3571
+ }
3282
3572
  if (counters.placeholderSkipped) {
3283
3573
  disclosures.push(
3284
3574
  `${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
@@ -3316,7 +3606,7 @@ var instructionTruthLens = {
3316
3606
  );
3317
3607
  }
3318
3608
  disclosures.push(
3319
- `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.`
3609
+ `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.`
3320
3610
  );
3321
3611
  return {
3322
3612
  lens: LENS_ID4,
@@ -3747,6 +4037,7 @@ async function loadFleetManifest(manifestPath) {
3747
4037
 
3748
4038
  // src/engine/fleet.ts
3749
4039
  init_config();
4040
+ init_detect();
3750
4041
  init_facts();
3751
4042
  init_util();
3752
4043
 
@@ -3875,7 +4166,7 @@ function parseMilestones(text) {
3875
4166
  }
3876
4167
 
3877
4168
  // src/engine/fleet.ts
3878
- var pExecFile3 = promisify(execFile);
4169
+ var pExecFile4 = promisify(execFile);
3879
4170
  var FLEET_LENS = "fleet-manifest";
3880
4171
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
3881
4172
  var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
@@ -3930,7 +4221,7 @@ function stateBudgetsFor(entry) {
3930
4221
  async function gitGrepFiles(root, needle, mode = "fixed") {
3931
4222
  try {
3932
4223
  const matchFlag = mode === "regex" ? "-E" : "-F";
3933
- const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
4224
+ const { stdout } = await pExecFile4("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3934
4225
  cwd: root,
3935
4226
  timeout: 8e3
3936
4227
  });
@@ -4353,6 +4644,31 @@ async function checkGateDrift(manifest, findings, disclosures) {
4353
4644
  }
4354
4645
  }
4355
4646
  }
4647
+ async function checkClaudePointers(manifest, findings) {
4648
+ for (const entry of manifest.entries) {
4649
+ const root = entry.resolvedRoot;
4650
+ if (!root || !await isDirectory(root)) continue;
4651
+ const check = await checkClaudePointer(root);
4652
+ if (check.ok) continue;
4653
+ findings.push(
4654
+ check.kind === "no-import" ? finding3(
4655
+ `${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
4656
+ "risk",
4657
+ `\`${entry.name}\` has a CLAUDE.md that never imports its AGENTS.md`,
4658
+ [`${entry.name}: ${check.detail}`],
4659
+ "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.",
4660
+ "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."
4661
+ ) : finding3(
4662
+ `${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
4663
+ "gap",
4664
+ `\`${entry.name}\` relies on the AGENTS.md fallback, which the installed Claude Code predates`,
4665
+ [`${entry.name}: ${check.detail}`],
4666
+ `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.`,
4667
+ "Update Claude Code, or create a CLAUDE.md beside AGENTS.md whose only import line is `@AGENTS.md`."
4668
+ )
4669
+ );
4670
+ }
4671
+ }
4356
4672
  async function collectWallFindings(manifest) {
4357
4673
  const findings = [];
4358
4674
  const disclosures = [];
@@ -4362,6 +4678,7 @@ async function collectWallFindings(manifest) {
4362
4678
  await checkHygieneNeedles(manifest, findings, disclosures);
4363
4679
  await checkGuardedEmails(manifest, findings, disclosures);
4364
4680
  await checkGateDrift(manifest, findings, disclosures);
4681
+ await checkClaudePointers(manifest, findings);
4365
4682
  return { findings, disclosures };
4366
4683
  }
4367
4684
  function stateAgeDays(facts) {
@@ -4708,7 +5025,6 @@ var STOP_WORDS = new Set(
4708
5025
  );
4709
5026
  var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
4710
5027
  var TRAIL_RE = /[.,;:!?)\]}"']*$/;
4711
- var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
4712
5028
  function promoteBareTokens(text, ctx) {
4713
5029
  const skips = {
4714
5030
  bareInvocations: 0,
@@ -4896,6 +5212,11 @@ async function runPremise(opts) {
4896
5212
  `${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
4897
5213
  );
4898
5214
  }
5215
+ if (counters.fetchedSkipped) {
5216
+ disclosures.push(
5217
+ `${counters.fetchedSkipped} path(s) are named beside the URL they are fetched from \u2014 another tree, not this repo; skipped, not flagged.`
5218
+ );
5219
+ }
4899
5220
  if (counters.placeholderSkipped) {
4900
5221
  disclosures.push(
4901
5222
  `${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`