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/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # etymd
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 29c7b31: The Claude Code pointer check follows Claude Code 2.1.277, which reads `AGENTS.md` when a
8
+ directory has no `CLAUDE.md`. A repo with `AGENTS.md` alone now passes and `fleet add` registers
9
+ it; it is reported (`claude-pointer-missing`, tier gap) only when the installed Claude Code is
10
+ older than 2.1.277, and `fleet add` prints a note instead of refusing. A `CLAUDE.md` that exists
11
+ without importing `@AGENTS.md` stays a risk and a refusal, because Claude Code reads that file
12
+ instead on every version. The version is read from `claude --version`; `ETYMD_CLAUDE_VERSION`
13
+ pins it, and `none` means no Claude Code on the machine.
14
+
15
+ ### Patch Changes
16
+
17
+ - 3dd2683: Dependency advisories cleared inside the declared ranges. `vitest` 4.1.10 → 4.1.11 closes
18
+ GHSA-82fw-gwwq-j7x9 (moderate: path traversal / arbitrary file read through the `@vitest/mocker`
19
+ redirect mock), and `js-yaml` 4.3.1 → 4.3.2 with its transitive 3.15.1 → 3.15.2 closes
20
+ GHSA-2883-xcg3-v3hh (high: `maxTotalMergeKeys` does not limit CPU use for empty merge sources).
21
+ Both are dev-only — the test runner and the changeset tooling — so the published build is
22
+ byte-identical; the lockfile also picks up the range-satisfying refresh those two pull in.
23
+
24
+ One advisory stays open, and deliberately: GHSA-g7r4-m6w7-qqqr (low: `esbuild` allows an arbitrary
25
+ file read when its development server runs on Windows) is fixed in esbuild 0.28.1, while `tsup`
26
+ 8.5.1 — the newest release — declares `esbuild: ^0.27.0`, so no in-range version of esbuild carries
27
+ the fix. The only automatic fix on offer is a downgrade to esbuild 0.27.2, which gives up five
28
+ patch releases to dodge a dev-server path this repo never takes: the build runs tsup, never an
29
+ esbuild server. It clears once tsup widens its range.
30
+
31
+ - 7d4e81f: `core.hooksPath` is read the way git reads it: a repo-relative path, an absolute path and a `~`
32
+ path can all name one directory, and the directory is the fact. An absolute path to the tracked
33
+ `.githooks/` now scans as `githooks` (wired) instead of `custom`, and the hooks are read from the
34
+ directory git actually runs — the literal-string comparison had also joined the absolute path
35
+ onto the repo root and looked for hooks at a path that does not exist, so a working pre-push gate
36
+ could read as absent. The fact is recorded repo-relative whenever the directory sits inside the
37
+ worktree, so a committed baseline never carries a machine path; a hooks directory outside the
38
+ repo stays absolute and is disclosed as `custom`.
39
+
40
+ ## 0.18.0
41
+
42
+ ### Minor Changes
43
+
44
+ - 41b91f6: Generated pre-push hooks preserve long filenames, whitespace, quotes, and leading
45
+ hyphens during shell-script discovery and checking, and a name carrying shell metacharacters
46
+ reaches the scan as data, never as code. Discovery fails closed instead of reporting success
47
+ with incomplete coverage: failed enumeration blocks, and a tracked regular file that exists
48
+ but cannot be read blocks, naming the file. Tracked paths with nothing readable behind them —
49
+ submodule entries, dangling symlinks, files deleted from the worktree while still tracked —
50
+ are counted and disclosed as skipped rather than blocking, so a submodule can no longer wedge
51
+ every push. Shebang reads are bounded to the first 4 KiB of the first line, so a large binary
52
+ cannot be copied into the gate's scratch file on every push and a shebang embedded deeper in
53
+ a document no longer drags the whole document into the checker. Regenerate existing hooks
54
+ with `etymd gates --yes` to apply the correction.
55
+ - 4a92435: The shebang classifier ships as a tracked, shebanged helper beside the hook
56
+ (`.githooks/discover-shell-scripts.sh`), so the scan it implements finds and checks it too —
57
+ the gate covers its own classifier, and the hook's most intricate code is lintable on its
58
+ own lines.
59
+
3
60
  ## 0.17.0
4
61
 
5
62
  ### Minor Changes
package/README.md CHANGED
@@ -174,7 +174,7 @@ personal and guarded repos. See [the fleet manifest](#the-fleet-manifest-experim
174
174
  | Command | What it does |
175
175
  | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
176
  | `etymd audit` | Verify every claim; ranked findings (risk → gap → polish) + ledger diff. `--lens`, `--truth`, `--json`, `--no-ledger`, `--fail-on <tier>`. |
177
- | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**. Never overwrites. |
177
+ | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**, with its `CLAUDE.md` pointer. Never overwrites. |
178
178
  | `etymd doctor` | Alias for `audit --truth`. |
179
179
  | `etymd context` | The economy view: per-file always-loaded footprint + extraction candidates. |
180
180
  | `etymd gates` | Install local git-hook gates (pre-commit / commit-msg / pre-push, plus a publish screen where something ships) built from your own check scripts — and from the repo's shell surface, where it has one. |
@@ -188,7 +188,7 @@ personal and guarded repos. See [the fleet manifest](#the-fleet-manifest-experim
188
188
  | `etymd accept` | `accept <id>` — record a finding as accepted reality; visible in the ledger, out of the report. |
189
189
  | `etymd fleet` | Sweep every project in a fleet manifest: read-only per-repo audits + manifest/wall checks. `--manifest`, `--only`, `--profile`, `--truth`, `--persist-ledgers`, `--json`, `--fail-on`. |
190
190
  | `etymd fleet check` | Validate the manifest pair alone (no lenses): dangling mappings, duplicate names, privacy leaks, undeclared trust, machine paths. Non-zero exit on any finding. |
191
- | `etymd fleet add` | `add <dir>` — register a project: scans it, asks for what no scan can derive, and refuses to write an entry missing a mandatory field. `--name`, `--kind`, `--profile`, `--trust`, `-y`. |
191
+ | `etymd fleet add` | `add <dir>` — register a project: scans it, asks for what no scan can derive, and refuses to write an entry missing a mandatory field, or a repo whose `AGENTS.md` Claude Code cannot see. `--name`, `--kind`, `--profile`, `--trust`, `-y`. |
192
192
  | `etymd fleet board` | Render the fleet board: every project's `MILESTONES.md` (contract key `milestones`, shape-checked by the sweep) plus a ranked initiatives table on one page. `--initiatives <file>`, `--out <file>`, `--json`. |
193
193
  | `etymd propose` | Score the sweep's improvement findings + recurring classes against a fleet-authored rubric — stable `proposal/1` records, read-only, deterministic, guarded entries excluded. `--rubric <file>` (required), `--manifest <file>` or `--from <fleet.json>`, `--json`. |
194
194
  | `etymd fleet dismiss` / `accept` | `<name> <id>` — resolve a project's finding from any cwd; guarded findings persist beside the manifest, never in the guarded worktree. |
@@ -520,7 +520,11 @@ Two fields the scan can never derive, so the manifest must declare them:
520
520
 
521
521
  `etymd fleet add <dir>` is the gate that keeps both true: it scans the project, prompts for what
522
522
  no scan can derive, and **refuses to write an incomplete entry**. Non-interactive runs (`--yes`,
523
- CI) must pass every mandatory value as a flag — there is deliberately no default.
523
+ CI) must pass every mandatory value as a flag — there is deliberately no default. It also refuses
524
+ a repo whose `CLAUDE.md` hides its `AGENTS.md`: Claude Code reads `CLAUDE.md` when one exists and
525
+ falls back to `AGENTS.md` (from 2.1.277) only when none does, so a `CLAUDE.md` must import
526
+ `@AGENTS.md` or be a symlink to it — the refusal prints the fix. A repo with `AGENTS.md` alone
527
+ registers; on a Claude Code older than 2.1.277 it gets a note, since that version cannot see it.
524
528
 
525
529
  How the sweep behaves:
526
530
 
@@ -543,8 +547,9 @@ How the sweep behaves:
543
547
  - **Wall checks.** Guarded contract files found inside a guarded worktree, unregistered checkouts
544
548
  under the fleet root whose remotes match `guardedHosts`, tracked `/Users/` paths in the manifest
545
549
  repo, private **needles** — the identifiers the local file holds (labels, dir names, hosts) —
546
- inside `trust: "public-repo"` entries, and guarded-host commit emails on personal entries — each
547
- a risk finding; each check that cannot run is disclosed.
550
+ inside `trust: "public-repo"` entries, guarded-host commit emails on personal entries, and
551
+ repos whose `AGENTS.md` no `CLAUDE.md` pointer or symlink makes visible to Claude Code
552
+ (`claude-pointer-missing`) — each a risk finding; each check that cannot run is disclosed.
548
553
  - **No global pointer.** `--manifest` is required unless the cwd holds `registry.json` — there
549
554
  is deliberately no env var and no home-directory pointer.
550
555
  - **Milestones and the fleet board.** A project declares its plan in one file — `MILESTONES.md`,
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { section, theme, print, renderBaselineDrift, glyph } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-446EZNAQ.js';
4
- import { VERSION } from './chunk-BUMOGHQA.js';
3
+ import { scanProject } from './chunk-DLHC7QON.js';
4
+ import { VERSION } from './chunk-L4KIIKXI.js';
5
5
  import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-P6ATKV2R.js';
6
- import { PACK_VERSION } from './chunk-YQZDYDAK.js';
6
+ import { PACK_VERSION } from './chunk-U7BCBJIH.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
 
9
9
  // src/commands/approve.ts
@@ -0,0 +1,11 @@
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,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, theme } from './chunk-HI7NWPRA.js';
3
- import { scanProject } from './chunk-446EZNAQ.js';
4
- import './chunk-BUMOGHQA.js';
3
+ import { scanProject } from './chunk-DLHC7QON.js';
4
+ import './chunk-L4KIIKXI.js';
5
5
  import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
6
- import './chunk-YQZDYDAK.js';
6
+ import './chunk-U7BCBJIH.js';
7
7
  import './chunk-4VPBP6K6.js';
8
8
  import { promises } from 'fs';
9
9
  import path from 'path';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { runAudit } from './chunk-WKP7M2B3.js';
3
- import { parseFailOnTier, meetsFailOn } from './chunk-FNA4R5KT.js';
2
+ import { runAudit } from './chunk-KIPD3N77.js';
3
+ import { parseFailOnTier, meetsFailOn } from './chunk-EYUW3UJU.js';
4
4
  import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-HI7NWPRA.js';
5
5
 
6
6
  // src/commands/audit.ts
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-BUMOGHQA.js';
3
- import { PACK_VERSION } from './chunk-YQZDYDAK.js';
4
- import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-4VPBP6K6.js';
2
+ import { VERSION } from './chunk-L4KIIKXI.js';
3
+ import { PACK_VERSION } from './chunk-U7BCBJIH.js';
4
+ import { normalizeRelPath, isDirectory, pathExists, readText, readJson, git, matchesAnyGlob } from './chunk-4VPBP6K6.js';
5
5
  import path from 'path';
6
+ import { execFile } from 'child_process';
6
7
  import { promises } from 'fs';
8
+ import { promisify } from 'util';
7
9
 
8
10
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
9
11
  "node_modules",
@@ -265,10 +267,17 @@ async function hasLintStagedConfig(root, pkg) {
265
267
  }
266
268
  return false;
267
269
  }
270
+ function normalizeHooksPath(root, raw) {
271
+ const abs = path.resolve(root, raw);
272
+ const rel = path.relative(root, abs);
273
+ const outside = path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`);
274
+ return outside ? abs : normalizeRelPath(rel) || ".";
275
+ }
268
276
  async function detectHooks(root, hooksPath, pkg) {
269
277
  const lintStaged = await hasLintStagedConfig(root, pkg);
270
278
  if (hooksPath) {
271
- if (hooksPath.replace(/\/+$/, "") === ".husky/_") {
279
+ const dir = normalizeHooksPath(root, hooksPath);
280
+ if (dir === ".husky/_") {
272
281
  const base2 = path.join(root, ".husky");
273
282
  return {
274
283
  source: "husky",
@@ -279,10 +288,10 @@ async function detectHooks(root, hooksPath, pkg) {
279
288
  lintStaged
280
289
  };
281
290
  }
282
- const base = path.join(root, hooksPath);
291
+ const base = path.resolve(root, dir);
283
292
  return {
284
- source: hooksPath === ".githooks" ? "githooks" : "custom",
285
- dir: hooksPath,
293
+ source: dir === ".githooks" ? "githooks" : "custom",
294
+ dir,
286
295
  preCommit: await pathExists(path.join(base, "pre-commit")),
287
296
  prePush: await pathExists(path.join(base, "pre-push")),
288
297
  commitMsg: await pathExists(path.join(base, "commit-msg")),
@@ -438,6 +447,69 @@ async function detectArtifacts(root) {
438
447
  });
439
448
  return artifacts;
440
449
  }
450
+ var ROOT_POINTER_IMPORT_RE = /^\s*@(\.\/)?AGENTS\.md\s*$/m;
451
+ var DOT_CLAUDE_IMPORT_RE = /^\s*@\.\.\/AGENTS\.md\s*$/m;
452
+ var CLAUDE_AGENTS_FALLBACK_VERSION = "2.1.277";
453
+ var pExecFile = promisify(execFile);
454
+ var claudeVersionMemo;
455
+ function detectClaudeCodeVersion() {
456
+ const override = process.env.ETYMD_CLAUDE_VERSION;
457
+ if (override !== void 0) {
458
+ return Promise.resolve(override === "none" || override === "" ? null : override);
459
+ }
460
+ claudeVersionMemo ??= pExecFile("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null).catch(() => null);
461
+ return claudeVersionMemo;
462
+ }
463
+ function versionBefore(a, b) {
464
+ const pa = a.split(".").map(Number);
465
+ const pb = b.split(".").map(Number);
466
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
467
+ const x = pa[i] ?? 0;
468
+ const y = pb[i] ?? 0;
469
+ if (x !== y) return x < y;
470
+ }
471
+ return false;
472
+ }
473
+ async function checkClaudePointer(root, claudeVersion) {
474
+ const agentsAbs = path.join(root, "AGENTS.md");
475
+ const claudeAbs = path.join(root, "CLAUDE.md");
476
+ if (!await pathExists(agentsAbs)) return { ok: true, via: "no-agents" };
477
+ const statOrNone = async (p) => {
478
+ try {
479
+ return await promises.stat(p);
480
+ } catch {
481
+ return null;
482
+ }
483
+ };
484
+ const [agentsSt, claudeSt] = await Promise.all([statOrNone(agentsAbs), statOrNone(claudeAbs)]);
485
+ if (agentsSt && claudeSt && agentsSt.ino !== 0 && agentsSt.ino === claudeSt.ino && agentsSt.dev === claudeSt.dev) {
486
+ return { ok: true, via: "same-file" };
487
+ }
488
+ const rootClaude = await readText(claudeAbs);
489
+ if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(rootClaude)) {
490
+ return { ok: true, via: "root-import" };
491
+ }
492
+ const dotClaude = await readText(path.join(root, ".claude", "CLAUDE.md"));
493
+ if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(dotClaude)) {
494
+ return { ok: true, via: "dotclaude-import" };
495
+ }
496
+ if (rootClaude !== null || dotClaude !== null) {
497
+ return {
498
+ ok: false,
499
+ kind: "no-import",
500
+ 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"
501
+ };
502
+ }
503
+ const version = claudeVersion === void 0 ? await detectClaudeCodeVersion() : claudeVersion;
504
+ if (version !== null && versionBefore(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
505
+ return {
506
+ ok: false,
507
+ kind: "old-reader",
508
+ detail: `AGENTS.md present, no CLAUDE.md, and Claude Code ${version} predates the AGENTS.md fallback (${CLAUDE_AGENTS_FALLBACK_VERSION})`
509
+ };
510
+ }
511
+ return { ok: true, via: "native-fallback" };
512
+ }
441
513
  async function walkTree(root) {
442
514
  const state = { budget: 2e4, truncated: false };
443
515
  const countFiles = async (dir) => {
@@ -558,11 +630,12 @@ async function scanProject(root, opts = {}) {
558
630
  const [branch, head, hooksPathRaw, authorsRaw, subjectsRaw] = isRepo ? await Promise.all([
559
631
  git(abs, ["rev-parse", "--abbrev-ref", "HEAD"]),
560
632
  git(abs, ["rev-parse", "--short", "HEAD"]),
561
- git(abs, ["config", "--get", "core.hooksPath"]),
633
+ // `--type=path` expands a leading `~` the way git itself does when it runs the hooks.
634
+ git(abs, ["config", "--type=path", "--get", "core.hooksPath"]),
562
635
  git(abs, ["log", "-200", "--format=%ae"]),
563
636
  git(abs, ["log", "-50", "--format=%s"])
564
637
  ]) : [null, null, null, null, null];
565
- const hooksPath = hooksPathRaw ?? void 0;
638
+ const hooksPath = hooksPathRaw ? normalizeHooksPath(abs, hooksPathRaw) : void 0;
566
639
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
567
640
  const shell = await detectShellSurface(abs, isRepo);
568
641
  const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
@@ -602,4 +675,4 @@ async function scanProject(root, opts = {}) {
602
675
  };
603
676
  }
604
677
 
605
- export { expandFileGlobs, scanProject };
678
+ export { CLAUDE_AGENTS_FALLBACK_VERSION, checkClaudePointer, expandFileGlobs, scanProject };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { expandFileGlobs } from './chunk-446EZNAQ.js';
2
+ import { expandFileGlobs } from './chunk-DLHC7QON.js';
3
3
  import { DEFAULT_CONFIG, CONFIG_FILE } from './chunk-P6ATKV2R.js';
4
4
  import { readText, isDirectory, matchesAnyGlob, normalizeRelPath, readJson, pathExists, git } from './chunk-4VPBP6K6.js';
5
5
  import path3 from 'path';
@@ -439,10 +439,13 @@ function extractCommandClaims(text) {
439
439
  return { scripts, filteredSkipped };
440
440
  }
441
441
  var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
442
+ var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
442
443
  var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
443
444
  ..."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(" ")
444
445
  ]);
445
446
  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;
447
+ var FETCH_CONTEXT_RE = /\b(?:fetch(?:es|ed|ing)?|pull(?:s|ed|ing)?|clone(?:s|d)?|download(?:s|ed|ing)?)\b/i;
448
+ var EXTERNAL_URL_RE = /https?:\/\/|\bwww\./i;
446
449
  var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
447
450
  var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
448
451
  function isPlaceholderClaim(token) {
@@ -476,14 +479,21 @@ ${line}`;
476
479
  }
477
480
  function extractPathClaims(text, opts = {}) {
478
481
  const prospectiveOnly = /* @__PURE__ */ new Map();
482
+ const fetchedOnly = /* @__PURE__ */ new Map();
479
483
  const namespacedOnly = /* @__PURE__ */ new Map();
480
484
  const placeholder = /* @__PURE__ */ new Set();
485
+ const dirs = /* @__PURE__ */ new Set();
481
486
  for (const m of text.matchAll(/`([^`\n]+)`/g)) {
482
487
  const token = m[1].trim();
483
488
  if (token.includes(" ") || token.length > 120) continue;
484
489
  if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
485
490
  continue;
486
491
  if (token.includes("://") || token.startsWith("www.")) continue;
492
+ if (token.includes("/")) {
493
+ const firstSegment = token.split("/")[0] ?? token;
494
+ const hostSuffix = HOSTNAME_RE.exec(firstSegment)?.[1]?.toLowerCase();
495
+ if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) continue;
496
+ }
487
497
  if (/[*?{}<>|]/.test(token)) continue;
488
498
  if (token.includes("@")) continue;
489
499
  if (!PATH_TOKEN_RE.test(token)) continue;
@@ -496,8 +506,12 @@ function extractPathClaims(text, opts = {}) {
496
506
  placeholder.add(claim);
497
507
  continue;
498
508
  }
499
- const prospective2 = CREATION_CONTEXT_RE.test(claimContext(text, m.index ?? 0));
509
+ if (isDirClaim) dirs.add(claim);
510
+ const context = claimContext(text, m.index ?? 0);
511
+ const prospective2 = CREATION_CONTEXT_RE.test(context);
500
512
  prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
513
+ const fetched2 = FETCH_CONTEXT_RE.test(context) && EXTERNAL_URL_RE.test(context);
514
+ fetchedOnly.set(claim, (fetchedOnly.get(claim) ?? true) && fetched2);
501
515
  if (opts.namespaces) {
502
516
  const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
503
517
  const prefixed = isNamespace(nsLead?.[1]);
@@ -506,13 +520,15 @@ function extractPathClaims(text, opts = {}) {
506
520
  }
507
521
  const paths = [];
508
522
  const prospective = [];
523
+ const fetched = [];
509
524
  const namespaced = [];
510
525
  for (const [claim, only] of prospectiveOnly) {
511
- if (only) prospective.push(claim);
526
+ if (fetchedOnly.get(claim)) fetched.push(claim);
527
+ else if (only) prospective.push(claim);
512
528
  else if (namespacedOnly.get(claim)) namespaced.push(claim);
513
529
  else paths.push(claim);
514
530
  }
515
- return { paths, prospective, namespaced, placeholder: [...placeholder] };
531
+ return { paths, dirs: [...dirs], prospective, fetched, namespaced, placeholder: [...placeholder] };
516
532
  }
517
533
  var LOCAL_REF_LEADINS = new Set(
518
534
  "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(" ")
@@ -582,12 +598,14 @@ async function buildTruthEnv(root, facts) {
582
598
  for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
583
599
  }
584
600
  const bases = [root, ...facts.packages.map((p) => path3.join(root, p.dir))];
585
- const pathResolves = async (claim) => {
601
+ const pathResolves = async (claim, fromDir) => {
586
602
  for (const base of bases) {
587
603
  if (await pathExists(path3.join(base, claim))) return true;
588
604
  if (await pathExists(path3.join(base, "src", claim))) return true;
589
605
  if (await pathExists(path3.join(base, "scripts", claim))) return true;
590
606
  }
607
+ if (fromDir && fromDir !== "." && await pathExists(path3.join(root, fromDir, claim)))
608
+ return true;
591
609
  return false;
592
610
  };
593
611
  const binResolves = async (name) => {
@@ -617,6 +635,7 @@ function emptyCounters() {
617
635
  unverifiableCommands: 0,
618
636
  gitignoredSkipped: 0,
619
637
  prospectiveSkipped: 0,
638
+ fetchedSkipped: 0,
620
639
  placeholderSkipped: 0,
621
640
  qualifiedRefsSkipped: 0,
622
641
  unresolvableRefs: 0,
@@ -659,19 +678,48 @@ async function checkTextClaims(env, file, opts, counters) {
659
678
  confidence: "high"
660
679
  });
661
680
  }
662
- const { paths, prospective, placeholder, namespaced } = extractPathClaims(file.text, {
663
- namespaces: opts.treatNamespacedPrefixes
664
- });
681
+ const { paths, dirs, prospective, fetched, placeholder, namespaced } = extractPathClaims(
682
+ file.text,
683
+ { namespaces: opts.treatNamespacedPrefixes }
684
+ );
665
685
  counters.prospectiveSkipped += prospective.length;
686
+ counters.fetchedSkipped += fetched.length;
666
687
  counters.placeholderSkipped += placeholder.length;
667
688
  counters.namespacedSkipped += namespaced.length;
668
- const missing = [];
689
+ const fromDir = path3.dirname(file.path);
690
+ const unresolved = [];
669
691
  for (const claim of paths) {
670
- if (await env.pathResolves(claim)) examined.push({ kind: "path", value: claim, exists: true });
692
+ if (await env.pathResolves(claim, fromDir))
693
+ examined.push({ kind: "path", value: claim, exists: true });
694
+ else unresolved.push(claim);
695
+ }
696
+ const proseBases = /* @__PURE__ */ new Set();
697
+ for (const dirClaim of dirs) {
698
+ if (await env.pathResolves(dirClaim, fromDir)) {
699
+ proseBases.add(dirClaim);
700
+ proseBases.add(path3.dirname(dirClaim));
701
+ }
702
+ }
703
+ const missing = [];
704
+ for (const claim of unresolved) {
705
+ let anchored = false;
706
+ for (const base of proseBases) {
707
+ if (await pathExists(path3.join(env.root, base, claim))) anchored = true;
708
+ else if (fromDir !== "." && await pathExists(path3.join(env.root, fromDir, base, claim)))
709
+ anchored = true;
710
+ if (anchored) break;
711
+ }
712
+ if (anchored) examined.push({ kind: "path", value: claim, exists: true });
671
713
  else missing.push(claim);
672
714
  }
673
715
  const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
674
716
  const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
717
+ const unproven = missing.filter((claim) => !gitignored.has(claim));
718
+ if (unproven.length) {
719
+ const dirOut = await git(env.root, ["check-ignore", ...unproven.map((claim) => `${claim}/`)]);
720
+ for (const line of (dirOut ?? "").split("\n").filter(Boolean))
721
+ gitignored.add(line.replace(/\/$/, ""));
722
+ }
675
723
  let pathFindings = 0;
676
724
  for (const claim of missing) {
677
725
  if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
@@ -780,4 +828,4 @@ function checkDecisionRefs(file, ledger, opts, counters) {
780
828
  return { findings, examined };
781
829
  }
782
830
 
783
- export { KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP, PATH_TOKEN_RE, buildTruthEnv, checkDecisionRefs, checkDocRefs, checkTextClaims, emptyCounters, listInstructionFiles, listStateDocuments, loadDecisionLedger, meetsFailOn, packageManagerUsage, parseFailOnTier, rankFindings, stateFreshnessLens };
831
+ export { HOSTNAME_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP, PATH_TOKEN_RE, buildTruthEnv, checkDecisionRefs, checkDocRefs, checkTextClaims, emptyCounters, listInstructionFiles, listStateDocuments, loadDecisionLedger, meetsFailOn, packageManagerUsage, parseFailOnTier, rankFindings, stateFreshnessLens };
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ import { isSafeGateCommand, generateAgentsMd, generateClaudePointerMd, isSelfBuildRepo, generatePreCommitHook, generateCommitMsgHook, generatePrePushHook, generateShellDiscoveryScript, generateArtifactCheckScript, fileOrigin } from './chunk-HSRHOVII.js';
3
+ import { DEFAULT_CONFIG } from './chunk-P6ATKV2R.js';
4
+ import { readText, pathExists } from './chunk-4VPBP6K6.js';
5
+ import path from 'path';
6
+
7
+ function derivedCommands(facts, existingHook) {
8
+ const c = facts.commands;
9
+ const base = [c.formatCheck, c.typecheck, c.lint].filter(
10
+ (k) => Boolean(k) && isSafeGateCommand(c.raw[k])
11
+ );
12
+ if (c.test && existingHook && new RegExp(`\\b${c.test}\\b`).test(existingHook)) {
13
+ base.push(c.test);
14
+ }
15
+ return base;
16
+ }
17
+ function riskReachability(facts) {
18
+ const reasons = [];
19
+ if (facts.publishRoute !== "none") {
20
+ reasons.push("an instruction file can name a package script that no longer exists");
21
+ }
22
+ if (facts.artifacts.some((a) => a.kind === "state" && a.exists)) {
23
+ reasons.push("a state doc can fall far enough behind the repo to escalate");
24
+ }
25
+ return reasons;
26
+ }
27
+ function deriveFailOn(facts, recorded) {
28
+ const reachable = riskReachability(facts);
29
+ if (recorded.explicit) return { failOn: recorded.failOn, source: "config", reachable };
30
+ if (recorded.failOn === "risk" && reachable.length === 0) {
31
+ return { failOn: "gap", source: "derived", reachable };
32
+ }
33
+ return { failOn: recorded.failOn, source: "default", reachable };
34
+ }
35
+ async function planWorkflow(root, facts, opts) {
36
+ const out = [];
37
+ const add = async (rel, contents, label, executable = false) => {
38
+ const abs = path.join(root, rel);
39
+ const exists = await pathExists(abs);
40
+ const existing = exists ? await readText(abs) : null;
41
+ const differs = exists ? existing !== contents : void 0;
42
+ const origin = differs && existing !== null ? fileOrigin(existing) : void 0;
43
+ out.push({
44
+ path: rel,
45
+ contents,
46
+ exists,
47
+ differs,
48
+ drift: origin === "pack" ? "stale" : origin,
49
+ executable,
50
+ label
51
+ });
52
+ };
53
+ if (opts.agents) {
54
+ await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
55
+ await add("CLAUDE.md", generateClaudePointerMd(), "Claude Code pointer to AGENTS.md");
56
+ }
57
+ if (opts.gates) {
58
+ const existingPrePush = await readText(path.join(root, ".githooks", "pre-push"));
59
+ const selfBuild = isSelfBuildRepo(facts);
60
+ await add(
61
+ ".githooks/pre-commit",
62
+ generatePreCommitHook(selfBuild),
63
+ "Process gate (pre-commit)",
64
+ true
65
+ );
66
+ await add(
67
+ ".githooks/commit-msg",
68
+ generateCommitMsgHook(opts.gateConfig),
69
+ "Message gate (content screen, format)",
70
+ true
71
+ );
72
+ const gateConfig = opts.gateConfig?.commands?.length ? opts.gateConfig : {
73
+ commands: derivedCommands(facts, existingPrePush ?? void 0),
74
+ failOn: opts.gateConfig?.failOn ?? DEFAULT_CONFIG.gates.failOn,
75
+ publishGate: opts.gateConfig?.publishGate,
76
+ commitFormat: opts.gateConfig?.commitFormat,
77
+ allowWriting: opts.gateConfig?.allowWriting ?? []
78
+ };
79
+ const tier = deriveFailOn(facts, {
80
+ failOn: gateConfig.failOn,
81
+ explicit: opts.gateFailOnPinned ?? false
82
+ });
83
+ await add(
84
+ ".githooks/pre-push",
85
+ generatePrePushHook(facts, { ...gateConfig, failOn: tier.failOn }, selfBuild),
86
+ "Correctness gate (pre-push)",
87
+ true
88
+ );
89
+ if (facts.shell?.scripts) {
90
+ await add(
91
+ ".githooks/discover-shell-scripts.sh",
92
+ generateShellDiscoveryScript(),
93
+ "Shell discovery (pre-push shellcheck helper)",
94
+ true
95
+ );
96
+ }
97
+ if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
98
+ await add(
99
+ "scripts/artifact-check.sh",
100
+ generateArtifactCheckScript(selfBuild),
101
+ "Content screen (published artifact)",
102
+ true
103
+ );
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ export { deriveFailOn, derivedCommands, planWorkflow, riskReachability };