etymd 0.19.0 → 0.19.2

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 (28) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/{approve-Y36ZO6IL.js → approve-Z4NMEGDM.js} +3 -3
  3. package/dist/audit-4PGVS2TW.js +11 -0
  4. package/dist/{brief-ILB6RMSQ.js → brief-2N6X3CFR.js} +3 -3
  5. package/dist/{chunk-U7BCBJIH.js → chunk-6FT7KSKU.js} +1 -1
  6. package/dist/{chunk-EYUW3UJU.js → chunk-DWPMANIC.js} +1 -1
  7. package/dist/{chunk-KIPD3N77.js → chunk-FG5XKFR2.js} +3 -3
  8. package/dist/{chunk-DLHC7QON.js → chunk-OKHNNWOV.js} +32 -15
  9. package/dist/{chunk-EZRK4MV3.js → chunk-TZGRV7UY.js} +1 -1
  10. package/dist/{chunk-L4KIIKXI.js → chunk-W6GGG4LG.js} +1 -1
  11. package/dist/{chunk-ZCOIZVSM.js → chunk-WIBN7PBH.js} +4 -4
  12. package/dist/{chunk-4IESOWHQ.js → chunk-WYJFZ2MB.js} +2 -2
  13. package/dist/{chunk-HSRHOVII.js → chunk-X5GR2EJK.js} +179 -59
  14. package/dist/cli.js +16 -16
  15. package/dist/{doctor-EHRADIUZ.js → doctor-7YG342YP.js} +6 -6
  16. package/dist/{fleet-KFF3N2O7.js → fleet-XZVQLY53.js} +8 -8
  17. package/dist/{gates-UJ23THID.js → gates-633B6G2T.js} +5 -5
  18. package/dist/{generate-6IPTCLYG.js → generate-S5KGZIIM.js} +3 -3
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +210 -73
  21. package/dist/{init-7HWVHZMF.js → init-SL3HEB3H.js} +12 -5
  22. package/dist/{premise-U6JIB2QG.js → premise-62AXRQUK.js} +4 -4
  23. package/dist/{propose-NZMEPZIL.js → propose-4UWLIFWJ.js} +6 -6
  24. package/dist/scan-M7STYIYQ.js +5 -0
  25. package/dist/{scan-DLKYG5BB.js → scan-VEHQEEXY.js} +3 -3
  26. package/package.json +1 -1
  27. package/dist/audit-RONSEYFS.js +0 -11
  28. package/dist/scan-V53PGAQ6.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 = "14";
23
+ PACK_VERSION = "16";
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.19.0",
33
+ version: "0.19.2",
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",
@@ -501,23 +501,40 @@ async function detectArtifacts(root) {
501
501
  });
502
502
  return artifacts;
503
503
  }
504
+ function withoutFencedCode(text) {
505
+ const kept = [];
506
+ let fence = null;
507
+ for (const line of text.split("\n")) {
508
+ if (fence === null) {
509
+ const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
510
+ if (open?.[1] && !(open[1][0] === "`" && open[2]?.includes("`"))) fence = open[1];
511
+ else kept.push(line);
512
+ } else {
513
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)?.[1];
514
+ if (close && close[0] === fence[0] && close.length >= fence.length) fence = null;
515
+ }
516
+ }
517
+ return kept.join("\n");
518
+ }
504
519
  function detectClaudeCodeVersion() {
505
520
  const override = process.env.ETYMD_CLAUDE_VERSION;
506
521
  if (override !== void 0) {
507
522
  return Promise.resolve(override === "none" || override === "" ? null : override);
508
523
  }
509
- claudeVersionMemo ??= pExecFile2("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null).catch(() => null);
524
+ claudeVersionMemo ??= pExecFile2("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/.exec(stdout)?.[1] ?? null).catch(() => null);
510
525
  return claudeVersionMemo;
511
526
  }
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;
527
+ function mayPredate(a, b) {
528
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(v.trim());
529
+ const pa = parse(a);
530
+ const pb = parse(b);
531
+ if (!pa || !pb) return true;
532
+ for (let i = 1; i <= 3; i++) {
533
+ const x = Number(pa[i]);
534
+ const y = Number(pb[i]);
518
535
  if (x !== y) return x < y;
519
536
  }
520
- return false;
537
+ return pa[4] !== void 0 && pb[4] === void 0;
521
538
  }
522
539
  async function checkClaudePointer(root, claudeVersion) {
523
540
  const agentsAbs = path2.join(root, "AGENTS.md");
@@ -535,11 +552,11 @@ async function checkClaudePointer(root, claudeVersion) {
535
552
  return { ok: true, via: "same-file" };
536
553
  }
537
554
  const rootClaude = await readText(claudeAbs);
538
- if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(rootClaude)) {
555
+ if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(withoutFencedCode(rootClaude))) {
539
556
  return { ok: true, via: "root-import" };
540
557
  }
541
558
  const dotClaude = await readText(path2.join(root, ".claude", "CLAUDE.md"));
542
- if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(dotClaude)) {
559
+ if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(withoutFencedCode(dotClaude))) {
543
560
  return { ok: true, via: "dotclaude-import" };
544
561
  }
545
562
  if (rootClaude !== null || dotClaude !== null) {
@@ -550,7 +567,7 @@ async function checkClaudePointer(root, claudeVersion) {
550
567
  };
551
568
  }
552
569
  const version = await detectClaudeCodeVersion() ;
553
- if (version !== null && versionBefore(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
570
+ if (version !== null && mayPredate(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
554
571
  return {
555
572
  ok: false,
556
573
  kind: "old-reader",
@@ -779,8 +796,8 @@ var init_detect = __esm({
779
796
  ];
780
797
  SHELL_SHEBANG = /^#!\s*\/\S*\b(?:ba|da|z)?sh\b|^#!\s*\/usr\/bin\/env\s+(?:ba|da|z)?sh\b/;
781
798
  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;
799
+ ROOT_POINTER_IMPORT_RE = /^ {0,3}@(\.\/)?AGENTS\.md[ \t]*$/m;
800
+ DOT_CLAUDE_IMPORT_RE = /^ {0,3}@\.\.\/AGENTS\.md[ \t]*$/m;
784
801
  CLAUDE_AGENTS_FALLBACK_VERSION = "2.1.277";
785
802
  pExecFile2 = promisify(execFile);
786
803
  }
@@ -1237,15 +1254,21 @@ function contentScreenCall(opts) {
1237
1254
  `${indent}fi`
1238
1255
  ].join("\n");
1239
1256
  }
1240
- function localHookCall(hook) {
1257
+ function localHookCall(hook, feedRefs = false) {
1258
+ const refsCapture = feedRefs ? `# git hands the pushed refs to pre-push ONCE, on stdin \u2014 one line per ref:
1259
+ # "<local ref> <local sha> <remote ref> <remote sha>". The shell gate below reads them too, so
1260
+ # they are captured here and fed to each.
1261
+ refs=$(cat)
1262
+ ` : "";
1263
+ const call = feedRefs ? `printf '%s\\n' "$refs" | "$LOCAL" "$@" || exit 1` : `"$LOCAL" "$@" || exit 1`;
1241
1264
  return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
1242
1265
  # is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
1243
1266
  # A guard running tests that build fixture repositories should scrub git's exported GIT_* names
1244
1267
  # first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
1245
1268
  # env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
1246
1269
  LOCAL="$(dirname "$0")/${hook}.local"
1247
- if [ -x "$LOCAL" ]; then
1248
- "$LOCAL" "$@" || exit 1
1270
+ ${refsCapture}if [ -x "$LOCAL" ]; then
1271
+ ${call}
1249
1272
  fi`;
1250
1273
  }
1251
1274
  function generatePreCommitHook(selfBuild = false) {
@@ -1254,10 +1277,10 @@ function generatePreCommitHook(selfBuild = false) {
1254
1277
 
1255
1278
  ${localHookCall("pre-commit")}
1256
1279
 
1257
- # Content screen \u2014 staged file bytes. Refuses to commit environment, guarded-side or identity
1258
- # detail into a repo whose history is (or could become) public. The checker and its patterns
1259
- # are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
1260
- # commit anywhere, active only where you opted in.
1280
+ # Content screen \u2014 staged file bytes. Refuses to commit detail about your environment, work
1281
+ # or identity into a repo whose history is (or could become) public. The checker and its
1282
+ # patterns are machine-local by design, so this is a NO-OP wherever no checker is installed:
1283
+ # safe to commit anywhere, active only where you opted in.
1261
1284
  #
1262
1285
  # Bypass, with a reason: git commit --no-verify
1263
1286
  ${contentGateResolution(selfBuild)}
@@ -1318,17 +1341,18 @@ function generateShellDiscoveryScript() {
1318
1341
  # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
1319
1342
  # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
1320
1343
  #
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
1344
+ # The hook runs it from inside a commit materialised from git's object store, so paths resolve
1345
+ # against that tree, never the working tree. A path with nothing readable behind it \u2014 a
1346
+ # submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
1347
+ # skip, never a block. A regular file
1324
1348
  # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
1325
1349
  # fails, naming the path.
1326
1350
  #
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.
1351
+ # The match/error protocol: grep reports "no match" as 1 and a failure as 2 or more, and only
1352
+ # the first is a verdict. Letting a failure fall through would pass a broken matcher as "not a
1353
+ # shell script" \u2014 the exact silent coverage-shrink the fail-closed rules exist to prevent. The
1354
+ # status is captured on the grep's own line (\`|| st=$?\`), so no command added later can come
1355
+ # between the grep and the check and silently replace the status being read.
1332
1356
  work=$1
1333
1357
  shift
1334
1358
  for file do
@@ -1344,33 +1368,42 @@ for file do
1344
1368
  exit 1
1345
1369
  }
1346
1370
  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
1371
+ st=0
1372
+ grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line" || st=$?
1373
+ case $st in
1374
+ (0)
1375
+ printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
1376
+ printf . >> "$work/count" || exit 1 ;;
1377
+ (1)
1378
+ st=0
1379
+ grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line" || st=$?
1380
+ case $st in
1381
+ (0) printf . >> "$work/zsh-count" || exit 1 ;;
1382
+ (1) ;;
1383
+ (*) exit 1 ;;
1384
+ esac ;;
1385
+ (*) exit 1 ;;
1386
+ esac
1358
1387
  done
1359
1388
  `);
1360
1389
  }
1361
1390
  function shellcheckStep() {
1362
1391
  return `
1363
1392
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
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,
1393
+ # script added later is covered without regenerating this hook. The commits BEING PUSHED are
1394
+ # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
1395
+ # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
1396
+ # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
1397
+ # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
1398
+ # is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
1366
1399
  # so the scan it implements finds and checks it too. zsh is NOT in the checked set:
1367
1400
  # the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
1368
1401
  # so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
1369
1402
  # at run time below, because a coverage hole that is silent is indistinguishable from coverage.
1370
1403
  # The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
1371
1404
  # 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.
1405
+ # behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
1406
+ # never a block.
1374
1407
  #
1375
1408
  # "the checker", not its name, on purpose: a comment whose first word is that name is read as
1376
1409
  # a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
@@ -1389,39 +1422,143 @@ if command -v shellcheck >/dev/null 2>&1; then
1389
1422
  shellcheck_tmp=$(mktemp -d) || exit 1
1390
1423
  trap 'rm -rf "$shellcheck_tmp"' 0
1391
1424
  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
1394
- exit 1
1395
- }
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
1425
+ # Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
1426
+ # script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
1427
+ # An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
1428
+ # branch: everything no remote already has is being pushed, so the range is the local sha
1429
+ # minus every remote-tracking ref \u2014 commits a remote already received were gated when they
1430
+ # landed there, and the residue is exactly this push's new commits. A remote sha this clone
1431
+ # has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
1432
+ # the same new-branch rule instead of refusing a push the gate could have read. Enumeration
1433
+ # failure refuses the push: a range the gate could not list is a range it did not read.
1434
+ # (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
1435
+ # in a case pattern.
1436
+ : > "$shellcheck_tmp/shas" || exit 1
1437
+ printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
1438
+ case "$lsha" in
1439
+ (*[!0]*) ;;
1440
+ (*) continue ;;
1441
+ esac
1442
+ case "$rsha" in
1443
+ (*[!0]*)
1444
+ if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
1445
+ git rev-list "$rsha..$lsha"
1446
+ else
1447
+ git rev-list "$lsha" --not --remotes
1448
+ fi ;;
1449
+ (*) git rev-list "$lsha" --not --remotes ;;
1450
+ esac >> "$shellcheck_tmp/shas" || exit 1
1451
+ done || {
1452
+ echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
1399
1453
  exit 1
1400
1454
  }
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"
1406
- 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"
1455
+ shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
1456
+ if [ -n "$shas" ]; then
1457
+ # Said up front: a first push from a fresh clone can carry the whole history, and a long
1458
+ # silent hook reads as a hung one.
1459
+ echo "\u203A shellcheck: $(printf '%s\\n' "$shas" | wc -l | tr -d ' ') commit(s) in the pushed range"
1460
+ else
1461
+ echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
1409
1462
  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'"
1463
+ # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
1464
+ # relative hook path would no longer point at it from there.
1465
+ discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
1466
+ for sha in $shas; do
1467
+ # A fresh directory per commit, removed once that commit is checked so a long push does
1468
+ # not pile up one full tree per commit; the subshell trap above still cleans the root on
1469
+ # every early exit.
1470
+ tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
1471
+ # Checked out through a scratch index, never \`git archive\`: archive honours the
1472
+ # export-ignore and export-subst attributes, so a script marked export-ignore would leave
1473
+ # the checked set without a word. read-tree + checkout-index writes every tracked blob.
1474
+ # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
1475
+ # network to be checked.
1476
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
1477
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
1478
+ || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
1479
+ git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
1480
+ 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
1481
+ exit 1
1482
+ fi
1483
+ if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
1484
+ echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
1485
+ exit 1
1486
+ fi
1487
+ : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1488
+ # Only the paths this commit adds, copies, modifies, renames or retypes against its first
1489
+ # parent. A script the commit did not touch has its parent's bytes, and that parent was
1490
+ # either gated when it reached the remote or sits in this range and is checked here; a
1491
+ # merge diffed against its first parent brings in everything the other side added.
1492
+ # Checking every script in every commit multiplied the cost by the range length, so a long
1493
+ # push of a script-heavy repo ran long enough to look hung. Two changes alter the verdict
1494
+ # on bytes nobody touched \u2014 the classifier deciding what is a script, and a .shellcheckrc
1495
+ # deciding what is a finding \u2014 so a commit that touches either, deletion included (a
1496
+ # removed .shellcheckrc re-enables every check it disabled), is checked whole. So is a
1497
+ # commit that adds or changes this hook: a repo adopting the gate after the fact has
1498
+ # scripts no gate ever read, and the commit installing it is where they get read once.
1499
+ short=$(git rev-parse --short "$sha" 2>/dev/null || echo "$sha")
1500
+ if git rev-parse -q --verify "\${sha}^1^{commit}" >/dev/null 2>&1; then
1501
+ git diff-tree -r -z --name-only --no-commit-id "\${sha}^1" "$sha" > "$shellcheck_tmp/touched" \\
1502
+ && git diff-tree -r -z --name-only --no-commit-id --diff-filter=ACMRT "\${sha}^1" "$sha" > "$shellcheck_tmp/tracked"
1503
+ else
1504
+ git diff-tree -r -z --name-only --no-commit-id --root "$sha" > "$shellcheck_tmp/touched" \\
1505
+ && cp "$shellcheck_tmp/touched" "$shellcheck_tmp/tracked"
1506
+ fi || {
1507
+ echo "etymd: cannot enumerate the paths $short changes for shellcheck" >&2
1414
1508
  exit 1
1415
1509
  }
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/^/ /'
1510
+ where="changed in $short"
1511
+ # Through a file, not a pipe: a pipeline reports only grep's status, and a failing tr would
1512
+ # hand grep nothing \u2014 a "no match" that silently takes the narrow path.
1513
+ tr '\\000' '\\n' < "$shellcheck_tmp/touched" > "$shellcheck_tmp/touched-lines" || exit 1
1514
+ # grep: 1 is "no match", a verdict; anything higher is the matcher failing. The status is
1515
+ # taken on the grep's own line so nothing added later can come between them.
1516
+ st=0
1517
+ grep -qE '(^|/)(\\.shellcheckrc|discover-shell-scripts\\.sh|pre-push)$' "$shellcheck_tmp/touched-lines" || st=$?
1518
+ case $st in
1519
+ (0)
1520
+ where="in $short (whole tree: the gate, its classifier or a .shellcheckrc changed)"
1521
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
1522
+ echo "etymd: cannot enumerate the tree of $short for shellcheck" >&2
1523
+ exit 1
1524
+ } ;;
1525
+ (1) ;;
1526
+ (*)
1527
+ echo "etymd: shell script discovery failed; cannot tell whether $short changes the gate, its classifier or a .shellcheckrc" >&2
1528
+ exit 1 ;;
1529
+ esac
1530
+ ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
1531
+ echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1532
+ exit 1
1533
+ }
1534
+ count=$(wc -c < "$shellcheck_tmp/count") || exit 1
1535
+ zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1536
+ skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1537
+ if [ "$skip_count" -gt 0 ]; then
1538
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
1423
1539
  fi
1424
- fi
1540
+ if [ "$zsh_count" -gt 0 ]; then
1541
+ echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1542
+ fi
1543
+ if [ "$count" -eq 0 ]; then
1544
+ echo "\u203A shellcheck: no shell script $where \u2014 nothing to check there"
1545
+ else
1546
+ echo "\u203A shellcheck ($((count)) scripts $where, blocking at severity=warning)"
1547
+ ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
1548
+ echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1549
+ exit 1
1550
+ }
1551
+ # Everything below the blocking bar, shown once the push is already cleared. Never affects
1552
+ # the exit code \u2014 advice that can fail a push is not advice.
1553
+ advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
1554
+ | grep -v ': warning:\\|: error:' || true)
1555
+ if [ -n "$advice" ]; then
1556
+ echo " \xB7 style/info (not blocking):"
1557
+ printf '%s\\n' "$advice" | sed 's/^/ /'
1558
+ fi
1559
+ fi
1560
+ rm -rf "$tree" || exit 1
1561
+ done
1425
1562
  ) || exit 1
1426
1563
  else
1427
1564
  echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
@@ -1455,7 +1592,7 @@ fi`;
1455
1592
  return stampGenerated(`#!/usr/bin/env sh
1456
1593
  # etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
1457
1594
 
1458
- ${localHookCall("pre-push")}${runner}
1595
+ ${localHookCall("pre-push", true)}${runner}
1459
1596
  ${body}${shellStep}
1460
1597
  ${auditStep}
1461
1598
 
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-EZRK4MV3.js';
4
- import './chunk-HSRHOVII.js';
3
+ import { planWorkflow } from './chunk-TZGRV7UY.js';
4
+ import './chunk-X5GR2EJK.js';
5
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-DLHC7QON.js';
7
- import { VERSION } from './chunk-L4KIIKXI.js';
6
+ import { scanProject, checkClaudePointer } from './chunk-OKHNNWOV.js';
7
+ import { VERSION } from './chunk-W6GGG4LG.js';
8
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
9
- import { PACK_VERSION } from './chunk-U7BCBJIH.js';
9
+ import { PACK_VERSION } from './chunk-6FT7KSKU.js';
10
10
  import { git, readText } from './chunk-4VPBP6K6.js';
11
11
  import { promises } from 'fs';
12
12
  import path from 'path';
@@ -102,6 +102,13 @@ async function run(opts) {
102
102
  section("Onboarded");
103
103
  for (const w of result.written) print(` ${glyph.ok} ${theme.dim("wrote")} ${theme.info(w)}`);
104
104
  for (const sk of result.skipped) print(` ${glyph.bullet} ${theme.dim("kept")} ${theme.dim(sk)}`);
105
+ const pointer = await checkClaudePointer(opts.cwd);
106
+ if (!pointer.ok && pointer.kind === "no-import") {
107
+ const line = pointer.detail.includes(".claude/CLAUDE.md") ? "@../AGENTS.md" : "@AGENTS.md";
108
+ print(
109
+ ` ${glyph.partial} ${theme.warn(pointer.detail)} ${theme.dim(`\u2014 Claude Code reads that file instead; add a full-line ${line} import`)}`
110
+ );
111
+ }
105
112
  print(
106
113
  ` ${glyph.ok} ${theme.dim("baseline approved \u2192")} ${theme.info(".etymd/baseline.json")} ${theme.dim("(commit it \u2014 drift is measured against it)")}`
107
114
  );
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, HOSTNAME_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP } from './chunk-EYUW3UJU.js';
2
+ import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, HOSTNAME_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP } from './chunk-DWPMANIC.js';
3
3
  import { print, section, theme, renderFindings } from './chunk-HI7NWPRA.js';
4
- import { scanProject } from './chunk-DLHC7QON.js';
5
- import './chunk-L4KIIKXI.js';
4
+ import { scanProject } from './chunk-OKHNNWOV.js';
5
+ import './chunk-W6GGG4LG.js';
6
6
  import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
7
- import './chunk-U7BCBJIH.js';
7
+ import './chunk-6FT7KSKU.js';
8
8
  import { readText, pathExists } from './chunk-4VPBP6K6.js';
9
9
  import path from 'path';
10
10
  import { promises } from 'fs';
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { sweepFleet, loadFleetManifest, FLEET_JSON_SCHEMA } from './chunk-ZCOIZVSM.js';
3
- import './chunk-KIPD3N77.js';
4
- import './chunk-EYUW3UJU.js';
2
+ import { sweepFleet, loadFleetManifest, FLEET_JSON_SCHEMA } from './chunk-WIBN7PBH.js';
3
+ import './chunk-FG5XKFR2.js';
4
+ import './chunk-DWPMANIC.js';
5
5
  import './chunk-3E2IPCRY.js';
6
6
  import { print, section, theme, renderFleetNotes } from './chunk-HI7NWPRA.js';
7
- import './chunk-DLHC7QON.js';
8
- import './chunk-L4KIIKXI.js';
7
+ import './chunk-OKHNNWOV.js';
8
+ import './chunk-W6GGG4LG.js';
9
9
  import './chunk-P6ATKV2R.js';
10
- import './chunk-U7BCBJIH.js';
10
+ import './chunk-6FT7KSKU.js';
11
11
  import { readText } from './chunk-4VPBP6K6.js';
12
12
  import path from 'path';
13
13
 
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-OKHNNWOV.js';
3
+ import './chunk-W6GGG4LG.js';
4
+ import './chunk-6FT7KSKU.js';
5
+ import './chunk-4VPBP6K6.js';
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, renderFacts, theme } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-DLHC7QON.js';
4
- import './chunk-L4KIIKXI.js';
3
+ import { scanProject } from './chunk-OKHNNWOV.js';
4
+ import './chunk-W6GGG4LG.js';
5
5
  import { writeCachedFacts } from './chunk-P6ATKV2R.js';
6
- import './chunk-U7BCBJIH.js';
6
+ import './chunk-6FT7KSKU.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/scan.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etymd",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "description": "Keep your agent instructions true — 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.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- export { run } from './chunk-4IESOWHQ.js';
3
- import './chunk-KIPD3N77.js';
4
- import './chunk-EYUW3UJU.js';
5
- import './chunk-3E2IPCRY.js';
6
- import './chunk-HI7NWPRA.js';
7
- import './chunk-DLHC7QON.js';
8
- import './chunk-L4KIIKXI.js';
9
- import './chunk-P6ATKV2R.js';
10
- import './chunk-U7BCBJIH.js';
11
- import './chunk-4VPBP6K6.js';
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export { scanProject } from './chunk-DLHC7QON.js';
3
- import './chunk-L4KIIKXI.js';
4
- import './chunk-U7BCBJIH.js';
5
- import './chunk-4VPBP6K6.js';