etymd 0.19.1 → 0.19.3

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 +19 -0
  2. package/dist/{approve-J6JU7MBX.js → approve-R24ZMJWB.js} +3 -3
  3. package/dist/audit-AZW4QGOE.js +11 -0
  4. package/dist/{brief-NHHILXIH.js → brief-HEJJU3GB.js} +3 -3
  5. package/dist/{chunk-UMKC3OWQ.js → chunk-5RRX2ONV.js} +3 -3
  6. package/dist/{chunk-GFUDKTC5.js → chunk-635GEE4V.js} +2 -2
  7. package/dist/{chunk-WKP72Z3J.js → chunk-7LRLA2XZ.js} +1 -1
  8. package/dist/{chunk-2DRA7S67.js → chunk-AXUYE7HH.js} +1 -1
  9. package/dist/{chunk-PQISPOB2.js → chunk-BXBSFDYL.js} +153 -64
  10. package/dist/{chunk-M7XDP6FT.js → chunk-IEHSNXR3.js} +4 -4
  11. package/dist/{chunk-KQFEKOON.js → chunk-VCEA7333.js} +1 -1
  12. package/dist/{chunk-6DJMKZGK.js → chunk-VVUEOA4V.js} +32 -15
  13. package/dist/{chunk-7N5JDXQG.js → chunk-XYJIDPKZ.js} +1 -1
  14. package/dist/cli.js +16 -16
  15. package/dist/{doctor-264Q5PLY.js → doctor-UGW5G5IZ.js} +6 -6
  16. package/dist/{fleet-VT625Q2J.js → fleet-Y22JBWLP.js} +8 -8
  17. package/dist/{gates-YUMSTKUG.js → gates-D6QPKN63.js} +5 -5
  18. package/dist/{generate-62GQSCKH.js → generate-5AF7V3DH.js} +3 -3
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +184 -78
  21. package/dist/{init-G4XXFXVD.js → init-FNLRAYP4.js} +12 -5
  22. package/dist/{premise-RNMAKMVA.js → premise-FYOWXHUH.js} +4 -4
  23. package/dist/{propose-35JXMEPH.js → propose-5XOMTLW7.js} +6 -6
  24. package/dist/{scan-PJH32H7C.js → scan-QBPWNLNC.js} +3 -3
  25. package/dist/scan-WPXLF2J4.js +5 -0
  26. package/package.json +1 -1
  27. package/dist/audit-5N2TU2X7.js +0 -11
  28. package/dist/scan-F6OW4ANK.js +0 -5
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-KQFEKOON.js';
2
+ import { VERSION } from './chunk-VCEA7333.js';
3
3
  import path from 'path';
4
4
  import { Command } from 'commander';
5
5
  import pc from 'picocolors';
@@ -31,7 +31,7 @@ program.command("audit").description("Verify every instruction claim against the
31
31
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
32
32
  ).action(
33
33
  (opts, cmd) => action(async () => {
34
- const { run } = await import('./audit-5N2TU2X7.js');
34
+ const { run } = await import('./audit-AZW4QGOE.js');
35
35
  await run({
36
36
  cwd: resolveCwd(cmd),
37
37
  json: opts.json,
@@ -44,7 +44,7 @@ program.command("audit").description("Verify every instruction claim against the
44
44
  );
45
45
  program.command("init").description("Onboard the truth guard: approve the baseline; scaffold AGENTS.md only if asked").option("-y, --yes", "accept defaults without prompting (never overwrites)").option("--with-agents", "also scaffold a minimal AGENTS.md where none exists (off by default)").action(
46
46
  (opts, cmd) => action(async () => {
47
- const { run } = await import('./init-G4XXFXVD.js');
47
+ const { run } = await import('./init-FNLRAYP4.js');
48
48
  await run({ cwd: resolveCwd(cmd), yes: opts.yes, withAgents: opts.withAgents });
49
49
  })
50
50
  );
@@ -52,19 +52,19 @@ program.command("approve").description(
52
52
  "Re-approve the committed baseline after intentional structural changes (non-interactive)"
53
53
  ).action(
54
54
  (_opts, cmd) => action(async () => {
55
- const { run } = await import('./approve-J6JU7MBX.js');
55
+ const { run } = await import('./approve-R24ZMJWB.js');
56
56
  await run({ cwd: resolveCwd(cmd) });
57
57
  })
58
58
  );
59
59
  program.command("scan").description("Deterministically reckon the project into a facts index").option("--json", "print the raw facts as JSON").option("--no-save", "do not write the .etymd cache").action(
60
60
  (opts, cmd) => action(async () => {
61
- const { run } = await import('./scan-PJH32H7C.js');
61
+ const { run } = await import('./scan-QBPWNLNC.js');
62
62
  await run({ cwd: resolveCwd(cmd), json: opts.json, save: opts.save });
63
63
  })
64
64
  );
65
65
  program.command("doctor").description('Alias for `audit --truth` \u2014 "are the recorded instructions still true?"').option("--json", "print findings as JSON").action(
66
66
  (opts, cmd) => action(async () => {
67
- const { run } = await import('./doctor-264Q5PLY.js');
67
+ const { run } = await import('./doctor-UGW5G5IZ.js');
68
68
  await run({ cwd: resolveCwd(cmd), json: opts.json });
69
69
  })
70
70
  );
@@ -76,7 +76,7 @@ program.command("context").description("Measure the always-loaded context footpr
76
76
  );
77
77
  program.command("brief").description("Emit a grounded briefing for the in-repo agent to complete the semantic layer").option("--human", "write a human onboarding brief instead of the agent briefing").action(
78
78
  (opts, cmd) => action(async () => {
79
- const { run } = await import('./brief-NHHILXIH.js');
79
+ const { run } = await import('./brief-HEJJU3GB.js');
80
80
  await run({ cwd: resolveCwd(cmd), human: opts.human });
81
81
  })
82
82
  );
@@ -90,7 +90,7 @@ program.command("premise").argument("[task]", "the task you are about to hand an
90
90
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
91
91
  ).action(
92
92
  (task, opts, cmd) => action(async () => {
93
- const { run } = await import('./premise-RNMAKMVA.js');
93
+ const { run } = await import('./premise-FYOWXHUH.js');
94
94
  await run({
95
95
  cwd: resolveCwd(cmd),
96
96
  task,
@@ -132,7 +132,7 @@ var fleet = program.command("fleet").description(
132
132
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
133
133
  ).action(
134
134
  (opts, cmd) => action(async () => {
135
- const { sweep } = await import('./fleet-VT625Q2J.js');
135
+ const { sweep } = await import('./fleet-Y22JBWLP.js');
136
136
  await sweep({
137
137
  cwd: resolveCwd(cmd),
138
138
  manifest: opts.manifest,
@@ -147,7 +147,7 @@ var fleet = program.command("fleet").description(
147
147
  );
148
148
  fleet.command("check").description("Validate the manifest pair only \u2014 no lenses: dangling mappings, duplicates, leaks").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").option("--json", "print the findings as JSON (EXPERIMENTAL through 0.2.x)").action(
149
149
  (_opts, cmd) => action(async () => {
150
- const { check } = await import('./fleet-VT625Q2J.js');
150
+ const { check } = await import('./fleet-Y22JBWLP.js');
151
151
  const opts = cmd.optsWithGlobals();
152
152
  await check({ cwd: resolveCwd(cmd), manifest: opts.manifest, json: opts.json });
153
153
  })
@@ -156,7 +156,7 @@ fleet.command("board").description(
156
156
  "Render the fleet board: every project's milestones (contract key `milestones`) plus a ranked initiatives table"
157
157
  ).option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").option("--initiatives <file>", "the hand-edited initiatives table (rank | id | initiative | \u2026)").option("--out <file>", "write the Markdown board here instead of printing it").option("--json", "print the board as JSON (EXPERIMENTAL)").action(
158
158
  (opts, cmd) => action(async () => {
159
- const { board } = await import('./fleet-VT625Q2J.js');
159
+ const { board } = await import('./fleet-Y22JBWLP.js');
160
160
  const shared = cmd.optsWithGlobals();
161
161
  await board({
162
162
  cwd: resolveCwd(cmd),
@@ -172,7 +172,7 @@ fleet.command("add").argument("<dir>", "directory of the project to register").d
172
172
  "public-repo | public-bound | private \u2014 mandatory for personal entries"
173
173
  ).option("-y, --yes", "skip prompts; every mandatory value must be passed as a flag").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
174
174
  (dir, opts, cmd) => action(async () => {
175
- const { add } = await import('./fleet-VT625Q2J.js');
175
+ const { add } = await import('./fleet-Y22JBWLP.js');
176
176
  const shared = cmd.optsWithGlobals();
177
177
  await add({
178
178
  cwd: resolveCwd(cmd),
@@ -195,7 +195,7 @@ fleet.command("dismiss").argument("<name>", "the registered project name").argum
195
195
  "Dismiss a project's finding from any cwd \u2014 guarded ledgers persist beside the manifest"
196
196
  ).requiredOption("--reason <text>", "why it is dismissed \u2014 recorded so the decision survives").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
197
197
  (name, id, _opts, cmd) => action(async () => {
198
- const { dismiss } = await import('./fleet-VT625Q2J.js');
198
+ const { dismiss } = await import('./fleet-Y22JBWLP.js');
199
199
  const opts = cmd.optsWithGlobals();
200
200
  await dismiss({
201
201
  cwd: resolveCwd(cmd),
@@ -210,7 +210,7 @@ fleet.command("accept").argument("<name>", "the registered project name").argume
210
210
  "Accept a project's finding as a known trade-off \u2014 guarded ledgers persist beside the manifest"
211
211
  ).option("--reason <text>", "optional note on why the trade-off is accepted").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
212
212
  (name, id, _opts, cmd) => action(async () => {
213
- const { accept } = await import('./fleet-VT625Q2J.js');
213
+ const { accept } = await import('./fleet-Y22JBWLP.js');
214
214
  const opts = cmd.optsWithGlobals();
215
215
  await accept({ cwd: resolveCwd(cmd), manifest: opts.manifest, name, id, reason: opts.reason });
216
216
  })
@@ -225,7 +225,7 @@ program.command("propose").description(
225
225
  "the fleet manifest \u2014 runs a read-only sweep now (no delta baseline move)"
226
226
  ).option("--from <file>", "read a stored `etymd fleet --json` output instead of sweeping").option("--json", "print the proposal/1 records as JSON (EXPERIMENTAL)").action(
227
227
  (opts, cmd) => action(async () => {
228
- const { run } = await import('./propose-35JXMEPH.js');
228
+ const { run } = await import('./propose-5XOMTLW7.js');
229
229
  await run({
230
230
  cwd: resolveCwd(cmd),
231
231
  manifest: opts.manifest,
@@ -256,7 +256,7 @@ program.command("screen").description(
256
256
  );
257
257
  program.command("gates").description("Install the local git-hook gates (process \u2192 pre-commit, correctness \u2192 pre-push)").option("--ci", "note about the CI review gate (ships later; local gates install now)").option("-y, --yes", "skip prompts; never overwrites a hand-edited hook").action(
258
258
  (opts, cmd) => action(async () => {
259
- const { run } = await import('./gates-YUMSTKUG.js');
259
+ const { run } = await import('./gates-D6QPKN63.js');
260
260
  await run({ cwd: resolveCwd(cmd), ci: opts.ci, yes: opts.yes });
261
261
  })
262
262
  );
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { run } from './chunk-GFUDKTC5.js';
3
- import './chunk-UMKC3OWQ.js';
4
- import './chunk-WKP72Z3J.js';
2
+ import { run } from './chunk-635GEE4V.js';
3
+ import './chunk-5RRX2ONV.js';
4
+ import './chunk-7LRLA2XZ.js';
5
5
  import './chunk-3E2IPCRY.js';
6
6
  import './chunk-HI7NWPRA.js';
7
- import './chunk-6DJMKZGK.js';
8
- import './chunk-KQFEKOON.js';
7
+ import './chunk-VVUEOA4V.js';
8
+ import './chunk-VCEA7333.js';
9
9
  import './chunk-P6ATKV2R.js';
10
- import './chunk-2DRA7S67.js';
10
+ import './chunk-AXUYE7HH.js';
11
11
  import './chunk-4VPBP6K6.js';
12
12
 
13
13
  // src/commands/doctor.ts
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { dismiss, accept } from './chunk-HOR4M6EC.js';
3
- import { sweepFleet, FLEET_JSON_SCHEMA, checkManifest, FLEET_TRUST_VALUES, isFleetTrust, MILESTONES_FILE, parseMilestones, parseInitiatives, BOARD_JSON_SCHEMA, renderBoard, loadFleetManifest, fleetLedgerTarget, ensureFindingRecorded } from './chunk-M7XDP6FT.js';
4
- import './chunk-UMKC3OWQ.js';
5
- import { parseFailOnTier, meetsFailOn } from './chunk-WKP72Z3J.js';
3
+ import { sweepFleet, FLEET_JSON_SCHEMA, checkManifest, FLEET_TRUST_VALUES, isFleetTrust, MILESTONES_FILE, parseMilestones, parseInitiatives, BOARD_JSON_SCHEMA, renderBoard, loadFleetManifest, fleetLedgerTarget, ensureFindingRecorded } from './chunk-IEHSNXR3.js';
4
+ import './chunk-5RRX2ONV.js';
5
+ import { parseFailOnTier, meetsFailOn } from './chunk-7LRLA2XZ.js';
6
6
  import './chunk-3E2IPCRY.js';
7
- import { generateClaudePointerMd } from './chunk-PQISPOB2.js';
7
+ import { generateClaudePointerMd } from './chunk-BXBSFDYL.js';
8
8
  import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
9
- import { checkClaudePointer, scanProject } from './chunk-6DJMKZGK.js';
10
- import './chunk-KQFEKOON.js';
9
+ import { checkClaudePointer, scanProject } from './chunk-VVUEOA4V.js';
10
+ import './chunk-VCEA7333.js';
11
11
  import './chunk-P6ATKV2R.js';
12
- import './chunk-2DRA7S67.js';
12
+ import './chunk-AXUYE7HH.js';
13
13
  import { readText, pathExists, git } from './chunk-4VPBP6K6.js';
14
14
  import { promises } from 'fs';
15
15
  import os from 'os';
@@ -240,7 +240,7 @@ async function add(opts) {
240
240
  throw new Error(
241
241
  `\`${name}\` has a CLAUDE.md that hides its AGENTS.md from Claude Code (${pointer.detail}). Add a full-line \`@AGENTS.md\` import to it, e.g.:
242
242
 
243
- ${generateClaudePointerMd().trimEnd().split("\n").join("\n")}
243
+ ${generateClaudePointerMd().trimEnd()}
244
244
 
245
245
  \u2014 or symlink either file to the other, or delete the CLAUDE.md \u2014 then re-run \`etymd fleet add\`.`
246
246
  );
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { deriveFailOn, derivedCommands, planWorkflow } from './chunk-7N5JDXQG.js';
4
- import { runPrefix, isSafeGateCommand } from './chunk-PQISPOB2.js';
3
+ import { deriveFailOn, derivedCommands, planWorkflow } from './chunk-XYJIDPKZ.js';
4
+ import { runPrefix, isSafeGateCommand } from './chunk-BXBSFDYL.js';
5
5
  import { section, print, glyph, theme, renderPlan } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-6DJMKZGK.js';
7
- import './chunk-KQFEKOON.js';
6
+ import { scanProject } from './chunk-VVUEOA4V.js';
7
+ import './chunk-VCEA7333.js';
8
8
  import { readConfig, CONFIG_FILE, configPath } from './chunk-P6ATKV2R.js';
9
- import './chunk-2DRA7S67.js';
9
+ import './chunk-AXUYE7HH.js';
10
10
  import { readText, git } from './chunk-4VPBP6K6.js';
11
11
  import { promises } from 'fs';
12
12
  import path from 'path';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- export { deriveFailOn, derivedCommands, planWorkflow, riskReachability } from './chunk-7N5JDXQG.js';
3
- import './chunk-PQISPOB2.js';
2
+ export { deriveFailOn, derivedCommands, planWorkflow, riskReachability } from './chunk-XYJIDPKZ.js';
3
+ import './chunk-BXBSFDYL.js';
4
4
  import './chunk-P6ATKV2R.js';
5
- import './chunk-2DRA7S67.js';
5
+ import './chunk-AXUYE7HH.js';
6
6
  import './chunk-4VPBP6K6.js';
package/dist/index.d.ts CHANGED
@@ -1127,7 +1127,7 @@ declare const gateIntegrityLens: Lens;
1127
1127
  * Gaps in the sequence are deliberate: a number stays retired even when the change that took it
1128
1128
  * never shipped, so a version cited in a baseline or a record means exactly one pack, ever.
1129
1129
  */
1130
- declare const PACK_VERSION = "15";
1130
+ declare const PACK_VERSION = "17";
1131
1131
 
1132
1132
  declare const VERSION: string;
1133
1133
  declare const NAME: string;
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 = "15";
23
+ PACK_VERSION = "17";
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.1",
33
+ version: "0.19.3",
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
  }
@@ -1319,49 +1336,86 @@ exit 0
1319
1336
  }
1320
1337
  function generateShellDiscoveryScript() {
1321
1338
  return stampGenerated(`#!/usr/bin/env sh
1322
- # etymd: shell script discovery for the pre-push shellcheck step. Arguments: the scratch
1323
- # directory the hook created, then the tracked paths to classify (NUL-delimited on the hook's
1324
- # side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
1325
- # dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
1339
+ # etymd: shell script discovery for the pre-push shellcheck step. Two calls per commit:
1340
+ # discover-shell-scripts.sh --skips <scratch> <ls-tree record>...
1341
+ # discover-shell-scripts.sh --commit <scratch> <tree> <commit> <commit>:<path>...
1342
+ # The first counts the changed paths that are symlinks or submodule entries. The second reads
1343
+ # the candidates \`git grep\` found with a line starting \`#!\`, and classifies each by its FIRST
1344
+ # line. Verdicts land in the scratch: scripts (NUL-delimited matches) and one dot per decision
1345
+ # into count / zsh-count / skip-count, tallied by the hook after the pipeline. Each script found
1346
+ # is written into <tree> at its path, so the checker reads it there.
1326
1347
  #
1327
- # The hook runs it from inside a commit materialised from git's object store, so paths resolve
1328
- # against that tree, never the working tree. A path with nothing readable behind it \u2014 a
1329
- # submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
1330
- # skip, never a block. A regular file
1331
- # that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
1332
- # fails, naming the path.
1348
+ # Every byte comes from git's object store as the raw blob \u2014 \`git cat-file blob\`, which
1349
+ # applies no eol, text or filter attribute. A checkout converts: under \`eol=crlf\` the shebang
1350
+ # line ends in a carriage return, the match below fails, and the script leaves the checked set
1351
+ # without a word. Only the entries handed in are read, never the whole commit.
1333
1352
  #
1334
- # The two \`[ "$?" -eq 1 ]\` guards are the match/error protocol: grep reports "no match" as 1
1335
- # and a failure as 2 or more, and only the first is a verdict. Dropping the guard would let a
1336
- # failing matcher pass as "not a shell script" \u2014 the exact silent coverage-shrink the
1337
- # fail-closed rules exist to prevent. The checker does not associate \`$?\` with the enclosing
1338
- # if-condition, which is why this shape survives the pass it serves; keep it that way.
1339
- work=$1
1340
- shift
1341
- for file do
1342
- if [ ! -f "./$file" ]; then
1343
- printf . >> "$work/skip-count" || exit 1
1344
- continue
1345
- fi
1346
- # 4096 bytes bound the read \u2014 a binary with no newline would otherwise be copied whole
1347
- # into the scratch on every push. The second head restores line-1-only semantics, so a
1348
- # shebang embedded on a LATER line of a document cannot match the patterns below.
1349
- head -c 4096 "./$file" > "$work/head-bytes" || {
1353
+ # A symlink or submodule entry has no script bytes of its own \u2014 a link's target is a tracked
1354
+ # path checked under its own name \u2014 so it is a disclosed skip, never a block. A candidate that
1355
+ # cannot be read fails, naming the path: coverage would otherwise silently shrink.
1356
+ #
1357
+ # The match/error protocol: grep reports "no match" as 1 and a failure as 2 or more, and only
1358
+ # the first is a verdict. Letting a failure fall through would pass a broken matcher as "not a
1359
+ # shell script" \u2014 the exact silent coverage-shrink the fail-closed rules exist to prevent. The
1360
+ # status is captured on the grep's own line (\`|| st=$?\`), so no command added later can come
1361
+ # between the grep and the check and silently replace the status being read.
1362
+ case \${1-} in
1363
+ (--skips)
1364
+ # Records only: a symlink or submodule entry is counted, everything else is left to the
1365
+ # candidate pass. Builtins only \u2014 this loop sees every changed path.
1366
+ work=$2
1367
+ shift 2
1368
+ for record do
1369
+ case \${record%% *} in
1370
+ (100644|100755) ;;
1371
+ (*) printf . >> "$work/skip-count" || exit 1 ;;
1372
+ esac
1373
+ done
1374
+ exit 0 ;;
1375
+ (--commit)
1376
+ [ $# -ge 4 ] || exit 1 ;;
1377
+ (*)
1378
+ echo "etymd: discover-shell-scripts.sh expects --commit or --skips; the pre-push beside it is older than this classifier \u2014 run 'etymd gates'" >&2
1379
+ exit 1 ;;
1380
+ esac
1381
+ work=$2
1382
+ tree=$3
1383
+ sha=$4
1384
+ shift 4
1385
+ for entry do
1386
+ file=\${entry#"$sha":}
1387
+ # 4096 bytes bound the classifying read \u2014 a binary with no newline would otherwise be copied
1388
+ # whole into the next step. The second head restores line-1-only semantics, so a shebang
1389
+ # embedded on a LATER line of a document cannot match the patterns below.
1390
+ git cat-file blob "$sha:$file" > "$work/blob" && head -c 4096 "$work/blob" > "$work/head-bytes" || {
1350
1391
  echo "etymd: cannot read tracked file for shellcheck: $file" >&2
1351
1392
  exit 1
1352
1393
  }
1353
1394
  head -n 1 "$work/head-bytes" > "$work/first-line" || exit 1
1354
- if grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line"; then
1355
- printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
1356
- printf . >> "$work/count" || exit 1
1357
- else
1358
- [ "$?" -eq 1 ] || exit 1
1359
- if grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line"; then
1360
- printf . >> "$work/zsh-count" || exit 1
1361
- else
1362
- [ "$?" -eq 1 ] || exit 1
1363
- fi
1364
- fi
1395
+ st=0
1396
+ grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line" || st=$?
1397
+ case $st in
1398
+ (0)
1399
+ case $file in
1400
+ (*/*) dir=\${file%/*} ;;
1401
+ (*) dir=. ;;
1402
+ esac
1403
+ mkdir -p -- "$tree/$dir" && mv -- "$work/blob" "$tree/$file" || {
1404
+ echo "etymd: cannot stage tracked file for shellcheck: $file" >&2
1405
+ exit 1
1406
+ }
1407
+ printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
1408
+ printf . >> "$work/count" || exit 1 ;;
1409
+ (1)
1410
+ st=0
1411
+ grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line" || st=$?
1412
+ case $st in
1413
+ (0) printf . >> "$work/zsh-count" || exit 1 ;;
1414
+ (1) ;;
1415
+ (*) exit 1 ;;
1416
+ esac ;;
1417
+ (*) exit 1 ;;
1418
+ esac
1365
1419
  done
1366
1420
  `);
1367
1421
  }
@@ -1369,7 +1423,7 @@ function shellcheckStep() {
1369
1423
  return `
1370
1424
  # Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
1371
1425
  # script added later is covered without regenerating this hook. The commits BEING PUSHED are
1372
- # the bytes that ship, so each one is materialised from git's object store and checked there \u2014
1426
+ # the bytes that ship, so each script is read as its raw blob from git's object store \u2014
1373
1427
  # never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
1374
1428
  # a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
1375
1429
  # bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
@@ -1431,37 +1485,87 @@ if command -v shellcheck >/dev/null 2>&1; then
1431
1485
  exit 1
1432
1486
  }
1433
1487
  shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
1434
- [ -n "$shas" ] || echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
1435
- # Resolved once, before any cd: the classifier runs inside each materialised tree, and a
1488
+ if [ -n "$shas" ]; then
1489
+ # Said up front: a first push from a fresh clone can carry the whole history, and a long
1490
+ # silent hook reads as a hung one.
1491
+ echo "\u203A shellcheck: $(printf '%s\\n' "$shas" | wc -l | tr -d ' ') commit(s) in the pushed range"
1492
+ else
1493
+ echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
1494
+ fi
1495
+ # Resolved once, before any cd: the checker runs inside each commit's scratch tree, and a
1436
1496
  # relative hook path would no longer point at it from there.
1437
1497
  discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
1438
1498
  for sha in $shas; do
1439
- # A fresh directory per commit, removed once that commit is checked so a long push does
1440
- # not pile up one full tree per commit; the subshell trap above still cleans the root on
1441
- # every early exit.
1499
+ # A fresh directory per commit holding only that commit's scripts, removed once it is
1500
+ # checked; the subshell trap above still cleans the root on every early exit.
1442
1501
  tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
1443
- # Checked out through a scratch index, never \`git archive\`: archive honours the
1444
- # export-ignore and export-subst attributes, so a script marked export-ignore would leave
1445
- # the checked set without a word. read-tree + checkout-index writes every tracked blob.
1446
- # Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
1447
- # network to be checked.
1448
- if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
1449
- || ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
1450
- || ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
1451
- git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
1452
- echo "\u2717 shellcheck: could not materialise $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") \u2014 refusing the push rather than certifying bytes this gate did not read" >&2
1453
- exit 1
1454
- fi
1455
- if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
1456
- echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
1502
+ if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null; then
1503
+ echo "\u2717 shellcheck: cannot read commit $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") \u2014 refusing the push rather than certifying bytes this gate did not read" >&2
1457
1504
  exit 1
1458
1505
  fi
1459
1506
  : > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
1460
- git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
1461
- echo "etymd: cannot enumerate the tree of $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") for shellcheck" >&2
1507
+ # Only the paths this commit adds, copies, modifies, renames or retypes against its first
1508
+ # parent. A script the commit did not touch has its parent's bytes, and that parent was
1509
+ # either gated when it reached the remote or sits in this range and is checked here; a
1510
+ # merge diffed against its first parent brings in everything the other side added.
1511
+ # Checking every script in every commit multiplied the cost by the range length, so a long
1512
+ # push of a script-heavy repo ran long enough to look hung. Two changes alter the verdict
1513
+ # on bytes nobody touched \u2014 the classifier deciding what is a script, and a .shellcheckrc
1514
+ # deciding what is a finding \u2014 so a commit that touches either, deletion included (a
1515
+ # removed .shellcheckrc re-enables every check it disabled), is checked whole. So is a
1516
+ # commit that adds or changes this hook: a repo adopting the gate after the fact has
1517
+ # scripts no gate ever read, and the commit installing it is where they get read once.
1518
+ short=$(git rev-parse --short "$sha" 2>/dev/null || echo "$sha")
1519
+ if git rev-parse -q --verify "\${sha}^1^{commit}" >/dev/null 2>&1; then
1520
+ git diff-tree -r -z --name-only --no-commit-id "\${sha}^1" "$sha" > "$shellcheck_tmp/touched" \\
1521
+ && git diff-tree -r -z --name-only --no-commit-id --diff-filter=ACMRT "\${sha}^1" "$sha" > "$shellcheck_tmp/tracked"
1522
+ else
1523
+ git diff-tree -r -z --name-only --no-commit-id --root "$sha" > "$shellcheck_tmp/touched" \\
1524
+ && cp "$shellcheck_tmp/touched" "$shellcheck_tmp/tracked"
1525
+ fi || {
1526
+ echo "etymd: cannot enumerate the paths $short changes for shellcheck" >&2
1462
1527
  exit 1
1463
1528
  }
1464
- ( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
1529
+ where="changed in $short"
1530
+ # Through a file, not a pipe: a pipeline reports only grep's status, and a failing tr would
1531
+ # hand grep nothing \u2014 a "no match" that silently takes the narrow path.
1532
+ tr '\\000' '\\n' < "$shellcheck_tmp/touched" > "$shellcheck_tmp/touched-lines" || exit 1
1533
+ # grep: 1 is "no match", a verdict; anything higher is the matcher failing. The status is
1534
+ # taken on the grep's own line so nothing added later can come between them.
1535
+ st=0
1536
+ grep -qE '(^|/)(\\.shellcheckrc|discover-shell-scripts\\.sh|pre-push)$' "$shellcheck_tmp/touched-lines" || st=$?
1537
+ case $st in
1538
+ (0)
1539
+ where="in $short (whole tree: the gate, its classifier or a .shellcheckrc changed)"
1540
+ git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
1541
+ echo "etymd: cannot enumerate the tree of $short for shellcheck" >&2
1542
+ exit 1
1543
+ } ;;
1544
+ (1) ;;
1545
+ (*)
1546
+ echo "etymd: shell script discovery failed; cannot tell whether $short changes the gate, its classifier or a .shellcheckrc" >&2
1547
+ exit 1 ;;
1548
+ esac
1549
+ # Only these paths are read, each as its raw blob \u2014 never a checkout of the commit, which
1550
+ # would apply eol and filter attributes (a CRLF shebang left the checked set silently) and
1551
+ # write the whole tree to check a handful of files. Never \`git archive\` either: it honours
1552
+ # export-ignore, so a script so marked would leave the set without a word.
1553
+ : > "$shellcheck_tmp/records" && : > "$shellcheck_tmp/candidates" || exit 1
1554
+ if [ -s "$shellcheck_tmp/tracked" ]; then
1555
+ # The candidates: changed blobs holding a line that starts \`#!\`, found in one pass over
1556
+ # the raw blobs (git grep reads no eol or filter conversion). Only these are read one by
1557
+ # one \u2014 a file with no such line cannot have a shebang first line. grep's "no match" is
1558
+ # 1, a verdict; xargs would fold it into a failure, so the wrapper maps 1 to 0 and lets
1559
+ # anything higher through as the failure it is.
1560
+ xargs -0 git --literal-pathspecs ls-tree -z "$sha" -- < "$shellcheck_tmp/tracked" > "$shellcheck_tmp/records" \\
1561
+ && xargs -0 sh -c 'git --literal-pathspecs grep -z -l --full-name -e "^#!" "$0" -- "$@"; st=$?; [ "$st" -le 1 ]' "$sha" \\
1562
+ < "$shellcheck_tmp/tracked" > "$shellcheck_tmp/candidates" || {
1563
+ echo "etymd: shell script discovery failed; cannot list the tracked entries of $short" >&2
1564
+ exit 1
1565
+ }
1566
+ fi
1567
+ xargs -0 "$discover" --skips "$shellcheck_tmp" < "$shellcheck_tmp/records" \\
1568
+ && xargs -0 "$discover" --commit "$shellcheck_tmp" "$tree" "$sha" < "$shellcheck_tmp/candidates" || {
1465
1569
  echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
1466
1570
  exit 1
1467
1571
  }
@@ -1469,13 +1573,15 @@ if command -v shellcheck >/dev/null 2>&1; then
1469
1573
  zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
1470
1574
  skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
1471
1575
  if [ "$skip_count" -gt 0 ]; then
1472
- echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
1576
+ echo "\u203A shellcheck: $((skip_count)) tracked path(s) that are symlinks or submodule entries \u2014 no script bytes of their own (a link's target is checked under its own path); not checked, not failed"
1473
1577
  fi
1474
1578
  if [ "$zsh_count" -gt 0 ]; then
1475
1579
  echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
1476
1580
  fi
1477
- if [ "$count" -gt 0 ]; then
1478
- echo "\u203A shellcheck ($((count)) scripts in $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha"), blocking at severity=warning)"
1581
+ if [ "$count" -eq 0 ]; then
1582
+ echo "\u203A shellcheck: no shell script $where \u2014 nothing to check there"
1583
+ else
1584
+ echo "\u203A shellcheck ($((count)) scripts $where, blocking at severity=warning)"
1479
1585
  ( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
1480
1586
  echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
1481
1587
  exit 1
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-7N5JDXQG.js';
4
- import './chunk-PQISPOB2.js';
3
+ import { planWorkflow } from './chunk-XYJIDPKZ.js';
4
+ import './chunk-BXBSFDYL.js';
5
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-6DJMKZGK.js';
7
- import { VERSION } from './chunk-KQFEKOON.js';
6
+ import { scanProject, checkClaudePointer } from './chunk-VVUEOA4V.js';
7
+ import { VERSION } from './chunk-VCEA7333.js';
8
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
9
- import { PACK_VERSION } from './chunk-2DRA7S67.js';
9
+ import { PACK_VERSION } from './chunk-AXUYE7HH.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
  );