etymd 0.17.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 (29) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +10 -5
  3. package/dist/{approve-VN4EDBR5.js → approve-Y36ZO6IL.js} +3 -3
  4. package/dist/audit-RONSEYFS.js +11 -0
  5. package/dist/{brief-7MWP35UI.js → brief-ILB6RMSQ.js} +3 -3
  6. package/dist/{chunk-ANFNXFRI.js → chunk-4IESOWHQ.js} +2 -2
  7. package/dist/{chunk-446EZNAQ.js → chunk-DLHC7QON.js} +83 -10
  8. package/dist/{chunk-FNA4R5KT.js → chunk-EYUW3UJU.js} +59 -11
  9. package/dist/chunk-EZRK4MV3.js +109 -0
  10. package/dist/{chunk-PIJZUDDQ.js → chunk-HSRHOVII.js} +113 -121
  11. package/dist/{chunk-WKP7M2B3.js → chunk-KIPD3N77.js} +11 -6
  12. package/dist/{chunk-BUMOGHQA.js → chunk-L4KIIKXI.js} +1 -1
  13. package/dist/{chunk-YQZDYDAK.js → chunk-U7BCBJIH.js} +1 -1
  14. package/dist/{chunk-LVR2DPR7.js → chunk-ZCOIZVSM.js} +30 -3
  15. package/dist/cli.js +16 -16
  16. package/dist/{doctor-R4QDCAN5.js → doctor-EHRADIUZ.js} +6 -6
  17. package/dist/{fleet-WPYEVHVL.js → fleet-KFF3N2O7.js} +22 -6
  18. package/dist/{gates-3HRGYCYY.js → gates-UJ23THID.js} +5 -4
  19. package/dist/{generate-FSJQFQPV.js → generate-6IPTCLYG.js} +3 -2
  20. package/dist/index.d.ts +21 -3
  21. package/dist/index.js +301 -49
  22. package/dist/{init-6RULX5JZ.js → init-7HWVHZMF.js} +5 -4
  23. package/dist/{premise-GFWHSR3K.js → premise-U6JIB2QG.js} +9 -5
  24. package/dist/{propose-IYNOIWAO.js → propose-NZMEPZIL.js} +6 -6
  25. package/dist/{scan-LLWW4YOU.js → scan-DLKYG5BB.js} +3 -3
  26. package/dist/scan-V53PGAQ6.js +5 -0
  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 = "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.17.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"));
@@ -1459,6 +1619,14 @@ async function planWorkflow(root, facts, opts) {
1459
1619
  "Correctness gate (pre-push)",
1460
1620
  true
1461
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
+ }
1462
1630
  if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
1463
1631
  await add(
1464
1632
  "scripts/artifact-check.sh",
@@ -1893,7 +2061,7 @@ async function localHookTools(root, facts, scripts) {
1893
2061
  const inertCompanions = [];
1894
2062
  const readHook = async (name) => {
1895
2063
  if (!hooks.dir) return empty;
1896
- const text = await readText(path2.join(root, hooks.dir, name));
2064
+ const text = await readText(path2.resolve(root, hooks.dir, name));
1897
2065
  if (!text) return empty;
1898
2066
  const tools = new Set(matchTools(text, scripts));
1899
2067
  const companion = await companionOf(root, hooks.dir, name, text, scripts);
@@ -2042,7 +2210,7 @@ async function buildGateInventory(root, facts) {
2042
2210
 
2043
2211
  // src/lenses/gate-integrity/screener.ts
2044
2212
  init_util();
2045
- var pExecFile2 = promisify(execFile);
2213
+ var pExecFile3 = promisify(execFile);
2046
2214
  var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
2047
2215
  var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
2048
2216
  var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
@@ -2059,7 +2227,7 @@ async function probeScreener(root, facts) {
2059
2227
  const doors = [];
2060
2228
  let devBuildArm = false;
2061
2229
  for (const name of HOOK_FILES) {
2062
- const text = await readText(path2.join(root, dir, name));
2230
+ const text = await readText(path2.resolve(root, dir, name));
2063
2231
  if (!text || !SCREEN_CALL_RE.test(text)) continue;
2064
2232
  doors.push(`${dir}/${name}`);
2065
2233
  if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
@@ -2079,7 +2247,7 @@ async function probeScreener(root, facts) {
2079
2247
  source = "path";
2080
2248
  }
2081
2249
  try {
2082
- await pExecFile2(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2250
+ await pExecFile3(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
2083
2251
  return { present: true, doors, runner, source, answersScreen: true };
2084
2252
  } catch (err) {
2085
2253
  const e = err;
@@ -2757,10 +2925,13 @@ function extractCommandClaims(text) {
2757
2925
  return { scripts, filteredSkipped };
2758
2926
  }
2759
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;
2760
2929
  var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
2761
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(" ")
2762
2931
  ]);
2763
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;
2764
2935
  var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
2765
2936
  var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
2766
2937
  function isPlaceholderClaim(token) {
@@ -2794,14 +2965,21 @@ ${line}`;
2794
2965
  }
2795
2966
  function extractPathClaims(text, opts = {}) {
2796
2967
  const prospectiveOnly = /* @__PURE__ */ new Map();
2968
+ const fetchedOnly = /* @__PURE__ */ new Map();
2797
2969
  const namespacedOnly = /* @__PURE__ */ new Map();
2798
2970
  const placeholder = /* @__PURE__ */ new Set();
2971
+ const dirs = /* @__PURE__ */ new Set();
2799
2972
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
2800
2973
  const token = m[1].trim();
2801
2974
  if (token.includes(" ") || token.length > 120) continue;
2802
2975
  if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
2803
2976
  continue;
2804
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
+ }
2805
2983
  if (/[*?{}<>|]/.test(token)) continue;
2806
2984
  if (token.includes("@")) continue;
2807
2985
  if (!PATH_TOKEN_RE.test(token)) continue;
@@ -2814,8 +2992,12 @@ function extractPathClaims(text, opts = {}) {
2814
2992
  placeholder.add(claim);
2815
2993
  continue;
2816
2994
  }
2817
- 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);
2818
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);
2819
3001
  if (opts.namespaces) {
2820
3002
  const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
2821
3003
  const prefixed = isNamespace(nsLead?.[1]);
@@ -2824,13 +3006,15 @@ function extractPathClaims(text, opts = {}) {
2824
3006
  }
2825
3007
  const paths = [];
2826
3008
  const prospective = [];
3009
+ const fetched = [];
2827
3010
  const namespaced = [];
2828
3011
  for (const [claim, only] of prospectiveOnly) {
2829
- if (only) prospective.push(claim);
3012
+ if (fetchedOnly.get(claim)) fetched.push(claim);
3013
+ else if (only) prospective.push(claim);
2830
3014
  else if (namespacedOnly.get(claim)) namespaced.push(claim);
2831
3015
  else paths.push(claim);
2832
3016
  }
2833
- return { paths, prospective, namespaced, placeholder: [...placeholder] };
3017
+ return { paths, dirs: [...dirs], prospective, fetched, namespaced, placeholder: [...placeholder] };
2834
3018
  }
2835
3019
  var LOCAL_REF_LEADINS = new Set(
2836
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(" ")
@@ -2902,12 +3086,14 @@ async function buildTruthEnv(root, facts) {
2902
3086
  for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
2903
3087
  }
2904
3088
  const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
2905
- const pathResolves = async (claim) => {
3089
+ const pathResolves = async (claim, fromDir) => {
2906
3090
  for (const base of bases) {
2907
3091
  if (await pathExists(path2.join(base, claim))) return true;
2908
3092
  if (await pathExists(path2.join(base, "src", claim))) return true;
2909
3093
  if (await pathExists(path2.join(base, "scripts", claim))) return true;
2910
3094
  }
3095
+ if (fromDir && fromDir !== "." && await pathExists(path2.join(root, fromDir, claim)))
3096
+ return true;
2911
3097
  return false;
2912
3098
  };
2913
3099
  const binResolves = async (name) => {
@@ -2937,6 +3123,7 @@ function emptyCounters() {
2937
3123
  unverifiableCommands: 0,
2938
3124
  gitignoredSkipped: 0,
2939
3125
  prospectiveSkipped: 0,
3126
+ fetchedSkipped: 0,
2940
3127
  placeholderSkipped: 0,
2941
3128
  qualifiedRefsSkipped: 0,
2942
3129
  unresolvableRefs: 0,
@@ -2979,19 +3166,48 @@ async function checkTextClaims(env, file, opts, counters) {
2979
3166
  confidence: "high"
2980
3167
  });
2981
3168
  }
2982
- const { paths, prospective, placeholder, namespaced } = extractPathClaims(file.text, {
2983
- namespaces: opts.treatNamespacedPrefixes
2984
- });
3169
+ const { paths, dirs, prospective, fetched, placeholder, namespaced } = extractPathClaims(
3170
+ file.text,
3171
+ { namespaces: opts.treatNamespacedPrefixes }
3172
+ );
2985
3173
  counters.prospectiveSkipped += prospective.length;
3174
+ counters.fetchedSkipped += fetched.length;
2986
3175
  counters.placeholderSkipped += placeholder.length;
2987
3176
  counters.namespacedSkipped += namespaced.length;
2988
- const missing = [];
3177
+ const fromDir = path2.dirname(file.path);
3178
+ const unresolved = [];
2989
3179
  for (const claim of paths) {
2990
- 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 });
2991
3201
  else missing.push(claim);
2992
3202
  }
2993
3203
  const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
2994
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
+ }
2995
3211
  let pathFindings = 0;
2996
3212
  for (const claim of missing) {
2997
3213
  if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
@@ -3279,6 +3495,11 @@ var instructionTruthLens = {
3279
3495
  `${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
3280
3496
  );
3281
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
+ }
3282
3503
  if (counters.placeholderSkipped) {
3283
3504
  disclosures.push(
3284
3505
  `${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
@@ -3316,7 +3537,7 @@ var instructionTruthLens = {
3316
3537
  );
3317
3538
  }
3318
3539
  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.`
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.`
3320
3541
  );
3321
3542
  return {
3322
3543
  lens: LENS_ID4,
@@ -3747,6 +3968,7 @@ async function loadFleetManifest(manifestPath) {
3747
3968
 
3748
3969
  // src/engine/fleet.ts
3749
3970
  init_config();
3971
+ init_detect();
3750
3972
  init_facts();
3751
3973
  init_util();
3752
3974
 
@@ -3875,7 +4097,7 @@ function parseMilestones(text) {
3875
4097
  }
3876
4098
 
3877
4099
  // src/engine/fleet.ts
3878
- var pExecFile3 = promisify(execFile);
4100
+ var pExecFile4 = promisify(execFile);
3879
4101
  var FLEET_LENS = "fleet-manifest";
3880
4102
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
3881
4103
  var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
@@ -3930,7 +4152,7 @@ function stateBudgetsFor(entry) {
3930
4152
  async function gitGrepFiles(root, needle, mode = "fixed") {
3931
4153
  try {
3932
4154
  const matchFlag = mode === "regex" ? "-E" : "-F";
3933
- const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
4155
+ const { stdout } = await pExecFile4("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
3934
4156
  cwd: root,
3935
4157
  timeout: 8e3
3936
4158
  });
@@ -4353,6 +4575,31 @@ async function checkGateDrift(manifest, findings, disclosures) {
4353
4575
  }
4354
4576
  }
4355
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
+ }
4356
4603
  async function collectWallFindings(manifest) {
4357
4604
  const findings = [];
4358
4605
  const disclosures = [];
@@ -4362,6 +4609,7 @@ async function collectWallFindings(manifest) {
4362
4609
  await checkHygieneNeedles(manifest, findings, disclosures);
4363
4610
  await checkGuardedEmails(manifest, findings, disclosures);
4364
4611
  await checkGateDrift(manifest, findings, disclosures);
4612
+ await checkClaudePointers(manifest, findings);
4365
4613
  return { findings, disclosures };
4366
4614
  }
4367
4615
  function stateAgeDays(facts) {
@@ -4708,7 +4956,6 @@ var STOP_WORDS = new Set(
4708
4956
  );
4709
4957
  var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
4710
4958
  var TRAIL_RE = /[.,;:!?)\]}"']*$/;
4711
- var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
4712
4959
  function promoteBareTokens(text, ctx) {
4713
4960
  const skips = {
4714
4961
  bareInvocations: 0,
@@ -4896,6 +5143,11 @@ async function runPremise(opts) {
4896
5143
  `${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
4897
5144
  );
4898
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
+ }
4899
5151
  if (counters.placeholderSkipped) {
4900
5152
  disclosures.push(
4901
5153
  `${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-PIJZUDDQ.js';
3
+ import { planWorkflow } from './chunk-EZRK4MV3.js';
4
+ import './chunk-HSRHOVII.js';
4
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
5
- import { scanProject } from './chunk-446EZNAQ.js';
6
- import { VERSION } from './chunk-BUMOGHQA.js';
6
+ import { scanProject } from './chunk-DLHC7QON.js';
7
+ import { VERSION } from './chunk-L4KIIKXI.js';
7
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
8
- import { PACK_VERSION } from './chunk-YQZDYDAK.js';
9
+ import { PACK_VERSION } from './chunk-U7BCBJIH.js';
9
10
  import { git, readText } from './chunk-4VPBP6K6.js';
10
11
  import { promises } from 'fs';
11
12
  import path from 'path';