etymd 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/README.md +145 -37
  3. package/dist/{approve-VC4V3OPW.js → approve-3B2UL7LG.js} +6 -4
  4. package/dist/audit-4YB3K62W.js +12 -0
  5. package/dist/{brief-TZ45MNHD.js → brief-6EEXGQUW.js} +6 -4
  6. package/dist/chunk-CRJHIXJH.js +5 -0
  7. package/dist/{chunk-LVLZLCND.js → chunk-DKC7MLWG.js} +1 -1
  8. package/dist/chunk-EF6BPBG6.js +22 -0
  9. package/dist/{chunk-BH37LELV.js → chunk-HOLFW5QY.js} +8 -5
  10. package/dist/chunk-LT67FU2Y.js +59 -0
  11. package/dist/{chunk-IW6K5HLV.js → chunk-MEJ4MBVL.js} +2 -2
  12. package/dist/{chunk-EUIHYD6F.js → chunk-MMPV67FW.js} +2 -2
  13. package/dist/{chunk-ANCCGYSH.js → chunk-NMZ3RHWW.js} +31 -59
  14. package/dist/{chunk-FZSIOCOZ.js → chunk-R74SJ7HS.js} +1 -1
  15. package/dist/{chunk-LBRNZZZF.js → chunk-TADKOW6P.js} +1 -99
  16. package/dist/{chunk-KMAJIQHC.js → chunk-V6NZEIHF.js} +7 -5
  17. package/dist/{chunk-MWIK6ML3.js → chunk-VFUYQLW7.js} +141 -25
  18. package/dist/{chunk-KBF3SW3N.js → chunk-WWBF4Y7K.js} +1 -1
  19. package/dist/chunk-YLNQQZ5F.js +102 -0
  20. package/dist/cli.js +50 -16
  21. package/dist/config-MX7OYI7F.js +4 -0
  22. package/dist/{context-GYCLR3TG.js → context-4DGXO7JR.js} +5 -3
  23. package/dist/doctor-T76ZNL4S.js +19 -0
  24. package/dist/{fleet-N3G5ZIGR.js → fleet-WOPKM2Z3.js} +248 -10
  25. package/dist/gates-WIGDS5VF.js +262 -0
  26. package/dist/generate-I75IEXXF.js +6 -0
  27. package/dist/index.d.ts +122 -33
  28. package/dist/index.js +737 -257
  29. package/dist/{init-4CQOLFQG.js → init-NSYVVMF2.js} +9 -5
  30. package/dist/ledger-TMIV45AA.js +5 -0
  31. package/dist/scan-BAD2UJAL.js +5 -0
  32. package/dist/{scan-CYOOGJFG.js → scan-COEPZJBO.js} +6 -4
  33. package/dist/screen-U7JMWZPS.js +134 -0
  34. package/package.json +2 -2
  35. package/dist/audit-LZJRJS5V.js +0 -9
  36. package/dist/doctor-6JDAHGDT.js +0 -16
  37. package/dist/gates-G5BCMRRV.js +0 -71
  38. package/dist/ledger-RHQJH5EA.js +0 -4
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-LVLZLCND.js';
2
+ import { VERSION } from './chunk-DKC7MLWG.js';
3
3
  import path from 'node: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-LZJRJS5V.js');
34
+ const { run } = await import('./audit-4YB3K62W.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 missing").option("-y, --yes", "accept defaults without prompting (never overwrites)").action(
46
46
  (opts, cmd) => action(async () => {
47
- const { run } = await import('./init-4CQOLFQG.js');
47
+ const { run } = await import('./init-NSYVVMF2.js');
48
48
  await run({ cwd: resolveCwd(cmd), yes: opts.yes });
49
49
  })
50
50
  );
@@ -52,49 +52,49 @@ 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-VC4V3OPW.js');
55
+ const { run } = await import('./approve-3B2UL7LG.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-CYOOGJFG.js');
61
+ const { run } = await import('./scan-COEPZJBO.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-6JDAHGDT.js');
67
+ const { run } = await import('./doctor-T76ZNL4S.js');
68
68
  await run({ cwd: resolveCwd(cmd), json: opts.json });
69
69
  })
70
70
  );
71
71
  program.command("context").description("Measure the always-loaded context footprint and flag extraction candidates").option("--json", "print the budget as JSON").action(
72
72
  (opts, cmd) => action(async () => {
73
- const { run } = await import('./context-GYCLR3TG.js');
73
+ const { run } = await import('./context-4DGXO7JR.js');
74
74
  await run({ cwd: resolveCwd(cmd), json: opts.json });
75
75
  })
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-TZ45MNHD.js');
79
+ const { run } = await import('./brief-6EEXGQUW.js');
80
80
  await run({ cwd: resolveCwd(cmd), human: opts.human });
81
81
  })
82
82
  );
83
83
  program.command("ledger").description("List every tracked finding grouped by status (open \xB7 accepted \xB7 dismissed \xB7 \u2026)").option("--json", "print the raw ledger as JSON").action(
84
84
  (opts, cmd) => action(async () => {
85
- const { list } = await import('./ledger-RHQJH5EA.js');
85
+ const { list } = await import('./ledger-TMIV45AA.js');
86
86
  await list({ cwd: resolveCwd(cmd), json: opts.json });
87
87
  })
88
88
  );
89
89
  program.command("dismiss").argument("<finding-id>", "the finding id (from `etymd audit`) to dismiss").description("Dismiss a finding as a false positive / not applicable (hidden from future audits)").requiredOption("--reason <text>", "why it is dismissed \u2014 recorded so the decision survives").action(
90
90
  (id, opts, cmd) => action(async () => {
91
- const { dismiss } = await import('./ledger-RHQJH5EA.js');
91
+ const { dismiss } = await import('./ledger-TMIV45AA.js');
92
92
  await dismiss({ cwd: resolveCwd(cmd), id, reason: opts.reason });
93
93
  })
94
94
  );
95
95
  program.command("accept").argument("<finding-id>", "the finding id (from `etymd audit`) to accept").description("Accept a real finding as a known trade-off (hidden from future audits)").option("--reason <text>", "optional note on why the trade-off is accepted").action(
96
96
  (id, opts, cmd) => action(async () => {
97
- const { accept } = await import('./ledger-RHQJH5EA.js');
97
+ const { accept } = await import('./ledger-TMIV45AA.js');
98
98
  await accept({ cwd: resolveCwd(cmd), id, reason: opts.reason });
99
99
  })
100
100
  );
@@ -111,7 +111,7 @@ var fleet = program.command("fleet").description(
111
111
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
112
112
  ).action(
113
113
  (opts, cmd) => action(async () => {
114
- const { sweep } = await import('./fleet-N3G5ZIGR.js');
114
+ const { sweep } = await import('./fleet-WOPKM2Z3.js');
115
115
  await sweep({
116
116
  cwd: resolveCwd(cmd),
117
117
  manifest: opts.manifest,
@@ -126,16 +126,35 @@ var fleet = program.command("fleet").description(
126
126
  );
127
127
  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(
128
128
  (_opts, cmd) => action(async () => {
129
- const { check } = await import('./fleet-N3G5ZIGR.js');
129
+ const { check } = await import('./fleet-WOPKM2Z3.js');
130
130
  const opts = cmd.optsWithGlobals();
131
131
  await check({ cwd: resolveCwd(cmd), manifest: opts.manifest, json: opts.json });
132
132
  })
133
133
  );
134
+ fleet.command("add").argument("<dir>", "directory of the project to register").description("Register a project \u2014 refuses to write an entry missing a mandatory field").option("--name <name>", "registered name (defaults to the directory's basename)").option("--kind <kind>", "entry kind (defaults to a value derived from the scan)").option("--profile <profile>", "personal | corp (default: personal)").option(
135
+ "--trust <level>",
136
+ "public-repo | public-bound | private \u2014 mandatory for personal entries"
137
+ ).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(
138
+ (dir, opts, cmd) => action(async () => {
139
+ const { add } = await import('./fleet-WOPKM2Z3.js');
140
+ const shared = cmd.optsWithGlobals();
141
+ await add({
142
+ cwd: resolveCwd(cmd),
143
+ manifest: shared.manifest,
144
+ target: dir,
145
+ name: opts.name,
146
+ kind: opts.kind,
147
+ profile: opts.profile,
148
+ trust: opts.trust,
149
+ yes: opts.yes
150
+ });
151
+ })
152
+ );
134
153
  fleet.command("dismiss").argument("<name>", "the registered project name").argument("<finding-id>", "the finding id (from `etymd fleet`) to dismiss").description(
135
154
  "Dismiss a project's finding from any cwd \u2014 corp ledgers persist beside the manifest"
136
155
  ).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(
137
156
  (name, id, _opts, cmd) => action(async () => {
138
- const { dismiss } = await import('./fleet-N3G5ZIGR.js');
157
+ const { dismiss } = await import('./fleet-WOPKM2Z3.js');
139
158
  const opts = cmd.optsWithGlobals();
140
159
  await dismiss({
141
160
  cwd: resolveCwd(cmd),
@@ -150,14 +169,29 @@ fleet.command("accept").argument("<name>", "the registered project name").argume
150
169
  "Accept a project's finding as a known trade-off \u2014 corp ledgers persist beside the manifest"
151
170
  ).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(
152
171
  (name, id, _opts, cmd) => action(async () => {
153
- const { accept } = await import('./fleet-N3G5ZIGR.js');
172
+ const { accept } = await import('./fleet-WOPKM2Z3.js');
154
173
  const opts = cmd.optsWithGlobals();
155
174
  await accept({ cwd: resolveCwd(cmd), manifest: opts.manifest, name, id, reason: opts.reason });
156
175
  })
157
176
  );
177
+ program.command("screen").description(
178
+ "Content screen: check for text that must never be published. Bring your own patterns \u2014 etymd ships none."
179
+ ).option("--staged", "screen what a commit is about to add (default)").option("--tree", "screen every tracked file \u2014 what is about to leave the machine").option("--message <file>", "screen a commit message (the commit-msg hook's $1)").option("--dir <dir>", "screen an unpacked build artifact \u2014 what actually ships").option("--patterns <file>", "pattern file (default: ~/.config/etymd/screen-patterns)").option("--advisory", "report without failing").action(
180
+ (opts, cmd) => action(async () => {
181
+ const { run } = await import('./screen-U7JMWZPS.js');
182
+ const scope = opts.message ? "message" : opts.dir ? "dir" : opts.tree ? "tree" : "staged";
183
+ await run({
184
+ cwd: resolveCwd(cmd),
185
+ scope,
186
+ target: opts.message ?? opts.dir,
187
+ patterns: opts.patterns,
188
+ advisory: opts.advisory
189
+ });
190
+ })
191
+ );
158
192
  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(
159
193
  (opts, cmd) => action(async () => {
160
- const { run } = await import('./gates-G5BCMRRV.js');
194
+ const { run } = await import('./gates-WIGDS5VF.js');
161
195
  await run({ cwd: resolveCwd(cmd), ci: opts.ci, yes: opts.yes });
162
196
  })
163
197
  );
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig } from './chunk-NMZ3RHWW.js';
3
+ import './chunk-R74SJ7HS.js';
4
+ import './chunk-YLNQQZ5F.js';
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { readConfig, measureContext } from './chunk-ANCCGYSH.js';
3
- import './chunk-FZSIOCOZ.js';
4
- import { print, renderContext } from './chunk-LBRNZZZF.js';
2
+ import { measureContext } from './chunk-LT67FU2Y.js';
3
+ import { readConfig } from './chunk-NMZ3RHWW.js';
4
+ import { print, renderContext } from './chunk-TADKOW6P.js';
5
+ import './chunk-R74SJ7HS.js';
6
+ import './chunk-YLNQQZ5F.js';
5
7
 
6
8
  // src/commands/context.ts
7
9
  async function run(opts) {
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './chunk-MEJ4MBVL.js';
3
+ import './chunk-V6NZEIHF.js';
4
+ import './chunk-LT67FU2Y.js';
5
+ import './chunk-WWBF4Y7K.js';
6
+ import './chunk-NMZ3RHWW.js';
7
+ import './chunk-TADKOW6P.js';
8
+ import './chunk-HOLFW5QY.js';
9
+ import './chunk-DKC7MLWG.js';
10
+ import './chunk-R74SJ7HS.js';
11
+ import './chunk-CRJHIXJH.js';
12
+ import './chunk-YLNQQZ5F.js';
13
+
14
+ // src/commands/doctor.ts
15
+ async function run2(opts) {
16
+ await run({ cwd: opts.cwd, json: opts.json, truth: true });
17
+ }
18
+
19
+ export { run2 as run };
@@ -1,18 +1,26 @@
1
1
  #!/usr/bin/env node
2
- import { dismiss, accept } from './chunk-EUIHYD6F.js';
3
- import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-KMAJIQHC.js';
4
- import { readLedger } from './chunk-KBF3SW3N.js';
5
- import { DEFAULT_CONFIG } from './chunk-ANCCGYSH.js';
6
- import './chunk-BH37LELV.js';
7
- import './chunk-LVLZLCND.js';
8
- import { ETYMD_DIR } from './chunk-FZSIOCOZ.js';
9
- import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph, pathExists, readText, isDirectory, git } from './chunk-LBRNZZZF.js';
2
+ import { dismiss, accept } from './chunk-MMPV67FW.js';
3
+ import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-V6NZEIHF.js';
4
+ import './chunk-LT67FU2Y.js';
5
+ import { readLedger } from './chunk-WWBF4Y7K.js';
6
+ import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
7
+ import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-TADKOW6P.js';
8
+ import { scanProject } from './chunk-HOLFW5QY.js';
9
+ import './chunk-DKC7MLWG.js';
10
+ import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
11
+ import './chunk-CRJHIXJH.js';
12
+ import { readText, pathExists, git, isDirectory } from './chunk-YLNQQZ5F.js';
10
13
  import { promises } from 'node:fs';
11
14
  import path from 'node:path';
15
+ import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
12
16
  import os from 'node:os';
13
17
  import { execFile } from 'node:child_process';
14
18
  import { promisify } from 'node:util';
15
19
 
20
+ var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
21
+ function isFleetTrust(value) {
22
+ return typeof value === "string" && FLEET_TRUST_VALUES.includes(value);
23
+ }
16
24
  function expandTilde(p) {
17
25
  if (p === "~") return os.homedir();
18
26
  if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
@@ -108,7 +116,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
108
116
  private: rec.private === true,
109
117
  path: asString(rec.path),
110
118
  upstream: asString(rec.upstream),
111
- trust: asString(rec.trust),
119
+ // An unknown value is NOT coerced to a default: trust gates content screening, so a typo
120
+ // must surface as "undeclared" (a finding) rather than quietly picking an answer.
121
+ trust: isFleetTrust(rec.trust) ? rec.trust : void 0,
122
+ gates: asString(rec.gates),
123
+ trustRaw: asString(rec.trust),
112
124
  staleAfterDays: typeof rec.staleAfterDays === "number" ? rec.staleAfterDays : void 0,
113
125
  stateBudget: typeof rec.stateBudget === "number" ? rec.stateBudget : void 0,
114
126
  contract,
@@ -225,6 +237,24 @@ async function loadFleetManifest(manifestPath) {
225
237
  manifest.machineProfile = local.data.machineProfile === "corp" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
226
238
  const rootRaw = asString(local.data.root) ?? asString(parsed.root);
227
239
  if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
240
+ const orientationRec = asRecord(parsed.orientation);
241
+ if (orientationRec) {
242
+ const orientationRoot = asString(orientationRec.root);
243
+ if (orientationRoot) manifest.orientation = { root: orientationRoot };
244
+ else {
245
+ manifest.problems.push({
246
+ kind: "bad-shape",
247
+ file: manifestFile,
248
+ detail: "`orientation` declares no `root` name \u2014 the fleet's orientation cannot be resolved"
249
+ });
250
+ }
251
+ } else if (parsed.orientation !== void 0) {
252
+ manifest.problems.push({
253
+ kind: "bad-shape",
254
+ file: manifestFile,
255
+ detail: '`orientation` must be an object (`{ "root": "<name>" }`) \u2014 ignored'
256
+ });
257
+ }
228
258
  loadRegistryEntries(parsed.projects, manifest, manifestFile);
229
259
  } else if (Array.isArray(parsed.sources)) {
230
260
  manifest.shape = "corpus";
@@ -367,6 +397,19 @@ async function checkManifest(manifest) {
367
397
  );
368
398
  }
369
399
  seen.add(entry.name);
400
+ if (entry.profile !== "corp" && !entry.private && !entry.trust) {
401
+ const bad = entry.trustRaw;
402
+ findings.push(
403
+ finding(
404
+ `${FLEET_LENS}/${bad ? "unknown-trust" : "undeclared-trust"}:${entry.name}`,
405
+ "risk",
406
+ bad ? `\`${entry.name}\` declares unknown trust \`${bad}\`` : `\`${entry.name}\` declares no \`trust\``,
407
+ [`${manifestFile}: ${entry.name}${bad ? `.trust = ${bad}` : " has no trust field"}`],
408
+ "Trust decides whether content screening applies. An unanswered entry is not a private one \u2014 publishing exposes ALL history, so a repo that went public without ever answering is exactly what a default would hide.",
409
+ `Declare one of ${FLEET_TRUST_VALUES.map((t) => `\`${t}\``).join(", ")}.`
410
+ )
411
+ );
412
+ }
370
413
  if (entry.private && entry.path) {
371
414
  findings.push(
372
415
  finding(
@@ -421,6 +464,18 @@ async function checkManifest(manifest) {
421
464
  }
422
465
  }
423
466
  }
467
+ if (manifest.orientation && !seen.has(manifest.orientation.root)) {
468
+ findings.push(
469
+ finding(
470
+ `${FLEET_LENS}/dangling-orientation:${manifest.orientation.root}`,
471
+ "gap",
472
+ `\`orientation.root\` names \`${manifest.orientation.root}\`, which is not a registered name`,
473
+ [`${manifestFile}: orientation.root = ${manifest.orientation.root}`],
474
+ "Every entry is guided by the orientation root implicitly \u2014 if it resolves to nothing, the whole fleet's orientation is a claim nothing can follow.",
475
+ "Point it at a registered name, or drop the block if this fleet has no orientation root."
476
+ )
477
+ );
478
+ }
424
479
  for (const name of Object.keys(manifest.localDirs)) {
425
480
  if (manifest.shape === "registry" && !seen.has(name)) {
426
481
  findings.push(
@@ -631,6 +686,56 @@ async function checkCorpEmails(manifest, findings, disclosures) {
631
686
  }
632
687
  }
633
688
  }
689
+ async function checkGateDrift(manifest, findings, disclosures) {
690
+ const { planWorkflow } = await import('./generate-I75IEXXF.js');
691
+ const { scanProject: scanProject2 } = await import('./scan-BAD2UJAL.js');
692
+ const { readConfig } = await import('./config-MX7OYI7F.js');
693
+ for (const entry of manifest.entries) {
694
+ const root = entry.resolvedRoot;
695
+ if (!root || !await isDirectory(root)) continue;
696
+ if (entry.profile === "corp" || entry.private) continue;
697
+ if (entry.gates === "none") {
698
+ disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
699
+ continue;
700
+ }
701
+ const facts = await scanProject2(root);
702
+ if (!facts.git.isRepo) continue;
703
+ if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
704
+ disclosures.push(
705
+ `${entry.name}: hooks managed by ${facts.hooks.source} \u2014 gate drift not comparable against the .githooks pack`
706
+ );
707
+ continue;
708
+ }
709
+ const { config } = await readConfig(root);
710
+ const planned = (await planWorkflow(root, facts, { agents: false, gates: true, gateConfig: config.gates })).filter((f) => f.executable);
711
+ const missing = planned.filter((f) => !f.exists).map((f) => f.path);
712
+ const stale = planned.filter((f) => f.exists && f.differs).map((f) => f.path);
713
+ if (missing.length) {
714
+ findings.push(
715
+ finding(
716
+ `${FLEET_LENS}/gate-missing:${entry.name}`,
717
+ "gap",
718
+ `\`${entry.name}\` is missing ${missing.length} gate${missing.length === 1 ? "" : "s"} the pack would install`,
719
+ missing.map((m) => `${entry.name}: no ${m}`),
720
+ "A gate absent in one repo is invisible from inside it \u2014 every commit there skips a check its siblings run.",
721
+ "Run `etymd gates` in that repo."
722
+ )
723
+ );
724
+ }
725
+ if (stale.length) {
726
+ findings.push(
727
+ finding(
728
+ `${FLEET_LENS}/gate-stale:${entry.name}`,
729
+ "polish",
730
+ `\`${entry.name}\` has ${stale.length} gate${stale.length === 1 ? "" : "s"} that differ from what its own inputs generate`,
731
+ stale.map((s) => `${entry.name}: ${s}`),
732
+ "A hand-edited or outdated hook drifts from the pack silently \u2014 the repo believes it runs checks the file no longer contains.",
733
+ "Re-run `etymd gates` (it asks before overwriting), or record the intent in .etymd/config.json."
734
+ )
735
+ );
736
+ }
737
+ }
738
+ }
634
739
  async function collectWallFindings(manifest) {
635
740
  const findings = [];
636
741
  const disclosures = [];
@@ -639,6 +744,7 @@ async function collectWallFindings(manifest) {
639
744
  await checkManifestRepoMachinePaths(manifest, findings, disclosures);
640
745
  await checkHygieneNeedles(manifest, findings, disclosures);
641
746
  await checkCorpEmails(manifest, findings, disclosures);
747
+ await checkGateDrift(manifest, findings, disclosures);
642
748
  return { findings, disclosures };
643
749
  }
644
750
  function stateAgeDays(facts) {
@@ -971,6 +1077,138 @@ async function check(opts) {
971
1077
  renderFindings(findings);
972
1078
  renderFleetNotes([], disclosures);
973
1079
  }
1080
+ async function add(opts) {
1081
+ const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1082
+ if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1083
+ if (manifest.shape !== "registry") {
1084
+ throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
1085
+ }
1086
+ const raw = await readText(manifest.manifestPath);
1087
+ if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
1088
+ const absTarget = path.resolve(opts.cwd, opts.target);
1089
+ if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
1090
+ const name = opts.name ?? path.basename(absTarget);
1091
+ if (manifest.entries.some((e) => e.name === name)) {
1092
+ throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
1093
+ }
1094
+ const facts = await scanProject(absTarget);
1095
+ const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
1096
+ section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
1097
+ let profile = opts.profile;
1098
+ if (!profile && !opts.yes) {
1099
+ const picked = await select({
1100
+ message: "Which side of the wall does this belong to?",
1101
+ initialValue: "personal",
1102
+ options: [
1103
+ { value: "personal", label: "personal \u2014 your own work" },
1104
+ { value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
1105
+ ]
1106
+ });
1107
+ if (isCancel(picked)) {
1108
+ cancel("No entry written.");
1109
+ return;
1110
+ }
1111
+ profile = picked;
1112
+ }
1113
+ profile = profile ?? "personal";
1114
+ if (profile !== "personal" && profile !== "corp") {
1115
+ throw new Error(`--profile must be personal or corp, got \`${profile}\``);
1116
+ }
1117
+ const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
1118
+ let kind = opts.kind;
1119
+ if (!kind && !opts.yes) {
1120
+ const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
1121
+ const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
1122
+ const picked = await select({
1123
+ message: "Kind?",
1124
+ initialValue: derivedKind,
1125
+ options: [
1126
+ ...choices.map((k) => ({
1127
+ value: k,
1128
+ label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
1129
+ })),
1130
+ { value: "", label: "something else\u2026" }
1131
+ ]
1132
+ });
1133
+ if (isCancel(picked)) {
1134
+ cancel("No entry written.");
1135
+ return;
1136
+ }
1137
+ kind = picked;
1138
+ if (!kind) {
1139
+ const typed = await text({
1140
+ message: "Kind (free text):",
1141
+ validate: (v) => v.trim() ? void 0 : "a kind is required"
1142
+ });
1143
+ if (isCancel(typed)) {
1144
+ cancel("No entry written.");
1145
+ return;
1146
+ }
1147
+ kind = typed.trim();
1148
+ }
1149
+ }
1150
+ kind = kind ?? derivedKind;
1151
+ let trust;
1152
+ if (profile === "personal") {
1153
+ trust = opts.trust;
1154
+ if (!trust && !opts.yes) {
1155
+ const picked = await select({
1156
+ message: "How exposed is this repo's history? (gates content screening)",
1157
+ options: [
1158
+ { value: "private", label: "private \u2014 not destined to be published" },
1159
+ { value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
1160
+ { value: "public-repo", label: "public-repo \u2014 already public" }
1161
+ ]
1162
+ });
1163
+ if (isCancel(picked)) {
1164
+ cancel("No entry written.");
1165
+ return;
1166
+ }
1167
+ trust = picked;
1168
+ }
1169
+ if (!trust) {
1170
+ throw new Error(
1171
+ `\`${name}\` needs a trust level: pass --trust <${FLEET_TRUST_VALUES.join("|")}> (non-interactive runs cannot be prompted, and there is deliberately no default \u2014 trust gates content screening)`
1172
+ );
1173
+ }
1174
+ if (!isFleetTrust(trust)) {
1175
+ throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
1176
+ }
1177
+ } else if (opts.trust) {
1178
+ throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
1179
+ }
1180
+ const entry = profile === "corp" ? { name, kind, profile, private: true } : {
1181
+ name,
1182
+ kind,
1183
+ profile,
1184
+ path: path.relative(manifest.root ?? manifest.dir, absTarget),
1185
+ trust,
1186
+ contract: {},
1187
+ links: {},
1188
+ // The scan does not collect remotes (they are not a workflow fact), so read it here —
1189
+ // recorded when present, simply absent when the repo has no origin yet.
1190
+ ...remote ? { remote } : {}
1191
+ };
1192
+ print(` ${glyph.bullet} ${theme.dim("entry")}`);
1193
+ print(
1194
+ JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
1195
+ );
1196
+ if (!opts.yes) {
1197
+ const ok = await confirm({
1198
+ message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
1199
+ });
1200
+ if (isCancel(ok) || !ok) {
1201
+ cancel("No entry written.");
1202
+ return;
1203
+ }
1204
+ }
1205
+ const doc = JSON.parse(raw);
1206
+ if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
1207
+ doc.projects.push(entry);
1208
+ await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
1209
+ print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
1210
+ print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
1211
+ }
974
1212
  async function resolveFleetFinding(opts, status) {
975
1213
  if (status === "dismissed" && !opts.reason?.trim()) {
976
1214
  throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
@@ -1001,4 +1239,4 @@ async function accept2(opts) {
1001
1239
  await resolveFleetFinding(opts, "accepted");
1002
1240
  }
1003
1241
 
1004
- export { accept2 as accept, check, dismiss2 as dismiss, sweep };
1242
+ export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };