etymd 0.15.0 → 0.17.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.
@@ -1,19 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { dismiss, accept } from './chunk-HOR4M6EC.js';
3
- import { runAudit } from './chunk-2RNQ6OLV.js';
4
- import { parseFailOnTier, meetsFailOn } from './chunk-IWG77WV3.js';
2
+ import { runAudit } from './chunk-WKP7M2B3.js';
5
3
  import { readLedger } from './chunk-3E2IPCRY.js';
6
- import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
7
- import { scanProject } from './chunk-Y6RZRED3.js';
8
- import './chunk-DBWDMIYO.js';
9
4
  import { DEFAULT_CONFIG, ETYMD_DIR } from './chunk-P6ATKV2R.js';
10
- import './chunk-YQZDYDAK.js';
11
- import { readText, pathExists, git, isDirectory } from './chunk-4VPBP6K6.js';
12
- import { promises } from 'fs';
5
+ import { readText, isDirectory, git, pathExists } from './chunk-4VPBP6K6.js';
13
6
  import os from 'os';
14
7
  import path from 'path';
15
- import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
16
8
  import { execFile } from 'child_process';
9
+ import { promises } from 'fs';
17
10
  import { promisify } from 'util';
18
11
 
19
12
  var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
@@ -25,14 +18,14 @@ function expandTilde(p) {
25
18
  if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
26
19
  return p;
27
20
  }
28
- var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"corp","dirs":{"<name>":"~/path/to/worktree"}}';
21
+ var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"guarded","dirs":{"<name>":"~/path/to/worktree"}}';
29
22
  var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
30
23
  function rejectUnsafeName(name, where, manifest, manifestFile) {
31
24
  if (SAFE_NAME.test(name)) return false;
32
25
  manifest.problems.push({
33
26
  kind: "bad-shape",
34
27
  file: manifestFile,
35
- detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and corp/<name>/ persistence paths)`
28
+ detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and guarded/<name>/ persistence paths)`
36
29
  });
37
30
  return true;
38
31
  }
@@ -73,7 +66,7 @@ async function readLocal(localPath, problems) {
73
66
  }
74
67
  }
75
68
  function loadRegistryEntries(projects, manifest, manifestFile) {
76
- const corpNames = [];
69
+ const guardedNames = [];
77
70
  for (const [i, rawEntry] of projects.entries()) {
78
71
  const rec = asRecord(rawEntry);
79
72
  const name = rec ? asString(rec.name) : void 0;
@@ -86,8 +79,8 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
86
79
  continue;
87
80
  }
88
81
  if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
89
- const profile = rec.profile === "corp" ? "corp" : "personal";
90
- if (rec.profile !== void 0 && rec.profile !== "corp" && rec.profile !== "personal") {
82
+ const profile = rec.profile === "guarded" ? "guarded" : "personal";
83
+ if (rec.profile !== void 0 && rec.profile !== "guarded" && rec.profile !== "personal") {
91
84
  manifest.problems.push({
92
85
  kind: "bad-shape",
93
86
  file: manifestFile,
@@ -125,12 +118,12 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
125
118
  contract,
126
119
  links: asStringMap(rec.links)
127
120
  };
128
- if (entry.profile === "corp" || entry.private) {
129
- corpNames.push(name);
121
+ if (entry.profile === "guarded" || entry.private) {
122
+ guardedNames.push(name);
130
123
  if (!manifest.localPresent) {
131
124
  entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
132
125
  } else if (manifest.machineProfile === "personal") {
133
- entry.unresolved = 'corp entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
126
+ entry.unresolved = 'guarded entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
134
127
  } else {
135
128
  const dir = manifest.localDirs[name];
136
129
  if (!dir) {
@@ -146,11 +139,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
146
139
  }
147
140
  manifest.entries.push(entry);
148
141
  }
149
- if (corpNames.length && !manifest.localPresent) {
142
+ if (guardedNames.length && !manifest.localPresent) {
150
143
  manifest.problems.push({
151
144
  kind: "local-missing",
152
145
  file: path.basename(manifest.localPath),
153
- detail: `${corpNames.length} corp/private entr${corpNames.length === 1 ? "y" : "ies"} (${corpNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
146
+ detail: `${guardedNames.length} guarded/private entr${guardedNames.length === 1 ? "y" : "ies"} (${guardedNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
154
147
  });
155
148
  }
156
149
  }
@@ -199,7 +192,7 @@ async function loadFleetManifest(manifestPath) {
199
192
  shape: "registry",
200
193
  manifestPath: abs,
201
194
  dir,
202
- corpHosts: [],
195
+ guardedHosts: [],
203
196
  labels: {},
204
197
  localDirs: {},
205
198
  localPath: path.join(dir, "registry.local.json"),
@@ -232,8 +225,8 @@ async function loadFleetManifest(manifestPath) {
232
225
  manifest.localPresent = local.present;
233
226
  manifest.localDirs = asStringMap(local.data.dirs);
234
227
  manifest.labels = asStringMap(local.data.labels);
235
- manifest.corpHosts = Array.isArray(local.data.corpHosts) ? local.data.corpHosts.filter((h) => typeof h === "string") : [];
236
- manifest.machineProfile = local.data.machineProfile === "corp" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
228
+ manifest.guardedHosts = Array.isArray(local.data.guardedHosts) ? local.data.guardedHosts.filter((h) => typeof h === "string") : [];
229
+ manifest.machineProfile = local.data.machineProfile === "guarded" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
237
230
  const rootRaw = asString(local.data.root) ?? asString(parsed.root);
238
231
  if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
239
232
  const orientationRec = asRecord(parsed.orientation);
@@ -271,10 +264,334 @@ async function loadFleetManifest(manifestPath) {
271
264
  }
272
265
  return manifest;
273
266
  }
267
+
268
+ // src/engine/milestones.ts
269
+ var MILESTONES_FILE = "MILESTONES.md";
270
+ var BOARD_JSON_SCHEMA = "fleet-board-experimental-0.1";
271
+ var MILESTONE_COLUMNS = [
272
+ "id",
273
+ "milestone",
274
+ "goal",
275
+ "status",
276
+ "next",
277
+ "effort",
278
+ "depends-on"
279
+ ];
280
+ var INITIATIVE_COLUMNS = [
281
+ "rank",
282
+ "id",
283
+ "initiative",
284
+ "goal",
285
+ "status",
286
+ "next",
287
+ "effort",
288
+ "projects",
289
+ "depends-on"
290
+ ];
291
+ var MILESTONE_GOALS = ["1", "2", "3"];
292
+ var MILESTONE_STATUSES = ["planned", "active", "blocked", "done"];
293
+ var MILESTONE_EFFORTS = ["S", "M", "L"];
294
+ var NONE_MARKERS = /* @__PURE__ */ new Set(["", "\u2014", "-", "\u2013", "none"]);
295
+ function splitRow(line) {
296
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
297
+ return trimmed.split("|").map((c) => c.trim());
298
+ }
299
+ function isSeparatorRow(line) {
300
+ const cells = splitRow(line);
301
+ return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c));
302
+ }
303
+ function firstTable(lines, startLine) {
304
+ for (let i = startLine; i < lines.length - 1; i++) {
305
+ const line = lines[i] ?? "";
306
+ const next = lines[i + 1] ?? "";
307
+ if (!line.trim().startsWith("|") || !isSeparatorRow(next)) continue;
308
+ const header = splitRow(line).map((h) => h.toLowerCase());
309
+ const rows = [];
310
+ for (let j = i + 2; j < lines.length; j++) {
311
+ const body = lines[j] ?? "";
312
+ if (!body.trim().startsWith("|")) break;
313
+ rows.push({ line: j + 1, cells: splitRow(body) });
314
+ }
315
+ return { header, rows, headerLine: i + 1 };
316
+ }
317
+ return null;
318
+ }
319
+ function parseList(cell2) {
320
+ if (NONE_MARKERS.has(cell2.trim().toLowerCase())) return [];
321
+ return cell2.split(",").map((s) => s.trim()).filter(Boolean);
322
+ }
323
+ function checkColumns(table, expected, problems) {
324
+ const got = table.header.join(" | ");
325
+ const want = expected.join(" | ");
326
+ if (got !== want) {
327
+ problems.push(
328
+ `line ${table.headerLine}: table columns are \`${got}\`; the standard is \`${want}\``
329
+ );
330
+ return false;
331
+ }
332
+ return true;
333
+ }
334
+ function checkVocabulary(line, field, value, allowed, problems) {
335
+ if (allowed.includes(value)) return true;
336
+ problems.push(
337
+ `line ${line}: ${field} is \`${value}\`; allowed: ${allowed.map((a) => `\`${a}\``).join(", ")}`
338
+ );
339
+ return false;
340
+ }
341
+ function parseMilestones(text) {
342
+ const problems = [];
343
+ const lines = text.split(/\r?\n/);
344
+ const headingIdx = lines.findIndex((l) => /^#\s/.test(l));
345
+ if (headingIdx === -1 || lines[headingIdx]?.trim() !== "# Milestones") {
346
+ problems.push(
347
+ headingIdx === -1 ? "no heading: the file must start with `# Milestones`" : `line ${headingIdx + 1}: first heading is \`${lines[headingIdx]?.trim()}\`; it must be \`# Milestones\``
348
+ );
349
+ }
350
+ const table = firstTable(lines, headingIdx === -1 ? 0 : headingIdx + 1);
351
+ if (!table) {
352
+ problems.push(
353
+ `no milestones table: expected \`| ${MILESTONE_COLUMNS.join(" | ")} |\` after the heading`
354
+ );
355
+ return { rows: [], problems };
356
+ }
357
+ if (!checkColumns(table, MILESTONE_COLUMNS, problems)) return { rows: [], problems };
358
+ const rows = [];
359
+ const ids = /* @__PURE__ */ new Set();
360
+ for (const { line, cells } of table.rows) {
361
+ if (cells.length !== MILESTONE_COLUMNS.length) {
362
+ problems.push(`line ${line}: ${cells.length} cells, expected ${MILESTONE_COLUMNS.length}`);
363
+ continue;
364
+ }
365
+ const [id, milestone, goal, status, next, effort, deps] = cells;
366
+ let ok = true;
367
+ if (!/^M\d+$/.test(id)) {
368
+ problems.push(`line ${line}: id \`${id}\` is not \`M<number>\``);
369
+ ok = false;
370
+ } else if (ids.has(id)) {
371
+ problems.push(`line ${line}: id \`${id}\` is used twice`);
372
+ ok = false;
373
+ }
374
+ if (!milestone) {
375
+ problems.push(`line ${line}: milestone title is empty`);
376
+ ok = false;
377
+ }
378
+ if (!next) {
379
+ problems.push(`line ${line}: next step is empty \u2014 a milestone without a next step is a wish`);
380
+ ok = false;
381
+ }
382
+ ok = checkVocabulary(line, "goal", goal, MILESTONE_GOALS, problems) && ok;
383
+ ok = checkVocabulary(line, "status", status, MILESTONE_STATUSES, problems) && ok;
384
+ ok = checkVocabulary(line, "effort", effort, MILESTONE_EFFORTS, problems) && ok;
385
+ if (!ok) continue;
386
+ ids.add(id);
387
+ rows.push({
388
+ id,
389
+ milestone,
390
+ goal,
391
+ status,
392
+ next,
393
+ effort,
394
+ dependsOn: parseList(deps)
395
+ });
396
+ }
397
+ for (const r of rows) {
398
+ for (const d of r.dependsOn) {
399
+ if (!ids.has(d)) problems.push(`${r.id} depends on \`${d}\`, which is not in the table`);
400
+ }
401
+ }
402
+ return { rows, problems };
403
+ }
404
+ function parseInitiatives(text) {
405
+ const problems = [];
406
+ const lines = text.split(/\r?\n/);
407
+ const table = firstTable(lines, 0);
408
+ if (!table) {
409
+ problems.push(`no initiatives table: expected \`| ${INITIATIVE_COLUMNS.join(" | ")} |\``);
410
+ return { rows: [], problems };
411
+ }
412
+ if (!checkColumns(table, INITIATIVE_COLUMNS, problems)) return { rows: [], problems };
413
+ const rows = [];
414
+ const ids = /* @__PURE__ */ new Set();
415
+ const ranks = /* @__PURE__ */ new Set();
416
+ for (const { line, cells } of table.rows) {
417
+ if (cells.length !== INITIATIVE_COLUMNS.length) {
418
+ problems.push(`line ${line}: ${cells.length} cells, expected ${INITIATIVE_COLUMNS.length}`);
419
+ continue;
420
+ }
421
+ const [rankRaw, id, initiative, goal, status, next, effort, projects, deps] = cells;
422
+ let ok = true;
423
+ const rank = Number(rankRaw);
424
+ if (!/^\d+$/.test(rankRaw) || rank < 1) {
425
+ problems.push(`line ${line}: rank \`${rankRaw}\` is not a positive integer`);
426
+ ok = false;
427
+ } else if (ranks.has(rank)) {
428
+ problems.push(`line ${line}: rank ${rank} is used twice \u2014 a rank is an order, not a score`);
429
+ ok = false;
430
+ }
431
+ if (!/^I\d+$/.test(id)) {
432
+ problems.push(`line ${line}: id \`${id}\` is not \`I<number>\``);
433
+ ok = false;
434
+ } else if (ids.has(id)) {
435
+ problems.push(`line ${line}: id \`${id}\` is used twice`);
436
+ ok = false;
437
+ }
438
+ if (!initiative) {
439
+ problems.push(`line ${line}: initiative title is empty`);
440
+ ok = false;
441
+ }
442
+ if (!next) {
443
+ problems.push(`line ${line}: next step is empty`);
444
+ ok = false;
445
+ }
446
+ ok = checkVocabulary(line, "goal", goal, MILESTONE_GOALS, problems) && ok;
447
+ ok = checkVocabulary(line, "status", status, MILESTONE_STATUSES, problems) && ok;
448
+ ok = checkVocabulary(line, "effort", effort, MILESTONE_EFFORTS, problems) && ok;
449
+ if (!ok) continue;
450
+ ids.add(id);
451
+ ranks.add(rank);
452
+ rows.push({
453
+ rank,
454
+ id,
455
+ milestone: initiative,
456
+ goal,
457
+ status,
458
+ next,
459
+ effort,
460
+ projects: parseList(projects),
461
+ dependsOn: parseList(deps)
462
+ });
463
+ }
464
+ for (const r of rows) {
465
+ for (const d of r.dependsOn) {
466
+ if (!ids.has(d)) problems.push(`${r.id} depends on \`${d}\`, which is not in the table`);
467
+ }
468
+ }
469
+ rows.sort((a, b) => a.rank - b.rank);
470
+ return { rows, problems };
471
+ }
472
+ function cell(s) {
473
+ return s.replace(/\|/g, "\\|");
474
+ }
475
+ function list(xs) {
476
+ return xs.length ? xs.join(", ") : "\u2014";
477
+ }
478
+ function countBy(rows) {
479
+ const c = { planned: 0, active: 0, blocked: 0, done: 0 };
480
+ for (const r of rows) c[r.status] += 1;
481
+ return c;
482
+ }
483
+ function statusLine(rows) {
484
+ const c = countBy(rows);
485
+ return MILESTONE_STATUSES.map((s) => `${s} ${c[s]}`).join(" \xB7 ");
486
+ }
487
+ function renderBoard(input) {
488
+ const out = [];
489
+ out.push("# Fleet board");
490
+ out.push("");
491
+ out.push(
492
+ `Generated ${input.generatedOn} by \`etymd fleet board\` from \`${input.manifest}\`. Do not edit: change a project's milestones file or the initiatives table and re-render.`
493
+ );
494
+ out.push("");
495
+ out.push("## Initiatives, ranked");
496
+ out.push("");
497
+ if (input.initiatives === null) {
498
+ out.push("No initiatives file given (`--initiatives <file>`).");
499
+ } else {
500
+ if (input.initiativesProblems.length) {
501
+ out.push("Problems in the initiatives table:");
502
+ out.push("");
503
+ for (const p of input.initiativesProblems) out.push(`- ${p}`);
504
+ out.push("");
505
+ }
506
+ if (input.initiatives.length === 0) {
507
+ out.push("No initiatives ranked yet.");
508
+ } else {
509
+ out.push(`| ${INITIATIVE_COLUMNS.join(" | ")} |`);
510
+ out.push(`|${INITIATIVE_COLUMNS.map(() => "---").join("|")}|`);
511
+ for (const i of input.initiatives) {
512
+ out.push(
513
+ `| ${i.rank} | ${i.id} | ${cell(i.milestone)} | ${i.goal} | ${i.status} | ${cell(i.next)} | ${i.effort} | ${cell(list(i.projects))} | ${list(i.dependsOn)} |`
514
+ );
515
+ }
516
+ }
517
+ }
518
+ out.push("");
519
+ out.push("## Milestones by project");
520
+ out.push("");
521
+ const all = [];
522
+ for (const p of input.projects) {
523
+ const kind = p.kind ? ` (${p.kind})` : "";
524
+ switch (p.state) {
525
+ case "ok":
526
+ case "declared-empty":
527
+ case "invalid": {
528
+ const head = p.state === "declared-empty" ? "declared, empty" : p.state === "invalid" ? `INVALID \u2014 ${p.problems.length} problem${p.problems.length === 1 ? "" : "s"}` : statusLine(p.rows);
529
+ out.push(`### ${p.name}${kind} \u2014 ${head}`);
530
+ out.push("");
531
+ if (p.problems.length) {
532
+ for (const pr of p.problems) out.push(`- ${pr}`);
533
+ out.push("");
534
+ }
535
+ if (p.rows.length) {
536
+ out.push(`| ${MILESTONE_COLUMNS.join(" | ")} |`);
537
+ out.push(`|${MILESTONE_COLUMNS.map(() => "---").join("|")}|`);
538
+ for (const r of p.rows) {
539
+ out.push(
540
+ `| ${r.id} | ${cell(r.milestone)} | ${r.goal} | ${r.status} | ${cell(r.next)} | ${r.effort} | ${list(r.dependsOn)} |`
541
+ );
542
+ }
543
+ out.push("");
544
+ all.push(...p.rows);
545
+ }
546
+ break;
547
+ }
548
+ case "not-declared":
549
+ out.push(`### ${p.name}${kind} \u2014 not declared (no \`milestones\` contract key)`);
550
+ out.push("");
551
+ break;
552
+ case "none":
553
+ out.push(`### ${p.name}${kind} \u2014 none, by declaration`);
554
+ out.push("");
555
+ break;
556
+ case "missing":
557
+ out.push(
558
+ `### ${p.name}${kind} \u2014 MISSING: \`${p.file ?? MILESTONES_FILE}\` is declared and absent`
559
+ );
560
+ out.push("");
561
+ break;
562
+ case "unresolved":
563
+ out.push(`### ${p.name}${kind} \u2014 not read: ${p.problems[0] ?? "unresolved"}`);
564
+ out.push("");
565
+ break;
566
+ }
567
+ }
568
+ out.push("## Totals");
569
+ out.push("");
570
+ const c = countBy(all);
571
+ out.push("| status | count |");
572
+ out.push("|---|---|");
573
+ for (const s of MILESTONE_STATUSES) out.push(`| ${s} | ${c[s]} |`);
574
+ out.push("");
575
+ const byGoal = { "1": 0, "2": 0, "3": 0 };
576
+ for (const r of all) if (r.status !== "done") byGoal[r.goal] = (byGoal[r.goal] ?? 0) + 1;
577
+ out.push("| goal | open milestones |");
578
+ out.push("|---|---|");
579
+ for (const g of MILESTONE_GOALS) out.push(`| ${g} | ${byGoal[g] ?? 0} |`);
580
+ out.push("");
581
+ const states = input.projects.reduce((acc, p) => {
582
+ acc[p.state] = (acc[p.state] ?? 0) + 1;
583
+ return acc;
584
+ }, {});
585
+ out.push(
586
+ `Projects: ${input.projects.length} \u2014 ` + Object.entries(states).map(([k, v]) => `${k} ${v}`).join(" \xB7 ")
587
+ );
588
+ out.push("");
589
+ return out.join("\n");
590
+ }
274
591
  var pExecFile = promisify(execFile);
275
592
  var FLEET_LENS = "fleet-manifest";
276
593
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
277
- var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
594
+ var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
278
595
  function recurringClasses(projects) {
279
596
  const byClass = /* @__PURE__ */ new Map();
280
597
  for (const p of projects) {
@@ -307,12 +624,12 @@ function finding(id, tier, claim, evidence, why, action) {
307
624
  confidence: "high"
308
625
  };
309
626
  }
310
- function corpPersistenceRoot(manifest, name) {
311
- const corpDir = path.join(manifest.dir, "corp");
312
- const root = path.resolve(corpDir, name);
313
- if (path.dirname(root) !== corpDir || root === corpDir) {
627
+ function guardedPersistenceRoot(manifest, name) {
628
+ const guardedDir = path.join(manifest.dir, "guarded");
629
+ const root = path.resolve(guardedDir, name);
630
+ if (path.dirname(root) !== guardedDir || root === guardedDir) {
314
631
  throw new Error(
315
- `entry name ${JSON.stringify(name)} escapes the corp persistence zone \u2014 refusing to resolve a ledger root for it`
632
+ `entry name ${JSON.stringify(name)} escapes the guarded persistence zone \u2014 refusing to resolve a ledger root for it`
316
633
  );
317
634
  }
318
635
  return root;
@@ -345,7 +662,7 @@ async function realpathOr(p) {
345
662
  return path.resolve(p);
346
663
  }
347
664
  }
348
- function emailMatchesCorpHosts(email, hosts) {
665
+ function emailMatchesGuardedHosts(email, hosts) {
349
666
  const domain = email.split("@")[1]?.toLowerCase();
350
667
  if (!domain) return false;
351
668
  return hosts.some((h) => {
@@ -396,7 +713,7 @@ async function checkManifest(manifest) {
396
713
  );
397
714
  }
398
715
  seen.add(entry.name);
399
- if (entry.profile !== "corp" && !entry.private && !entry.trust) {
716
+ if (entry.profile !== "guarded" && !entry.private && !entry.trust) {
400
717
  const bad = entry.trustRaw;
401
718
  findings.push(
402
719
  finding(
@@ -434,14 +751,14 @@ async function checkManifest(manifest) {
434
751
  )
435
752
  );
436
753
  }
437
- if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "corp" || entry.private)) {
754
+ if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "guarded" || entry.private)) {
438
755
  findings.push(
439
756
  finding(
440
757
  `${FLEET_LENS}/dangling-dir:${entry.name}`,
441
758
  "gap",
442
759
  `\`${entry.name}\` has no usable directory mapping`,
443
760
  [`${localFile}: ${entry.unresolved}`],
444
- "An unmapped corp entry silently drops out of every sweep on the machine that most needs it.",
761
+ "An unmapped guarded entry silently drops out of every sweep on the machine that most needs it.",
445
762
  `Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
446
763
  )
447
764
  );
@@ -505,28 +822,28 @@ async function checkManifest(manifest) {
505
822
  );
506
823
  }
507
824
  if (manifest.machineProfile === "personal") {
508
- const corpCount = manifest.entries.filter((e) => e.profile === "corp" || e.private).length;
509
- if (corpCount) {
825
+ const guardedCount = manifest.entries.filter((e) => e.profile === "guarded" || e.private).length;
826
+ if (guardedCount) {
510
827
  disclosures.push(
511
- `machineProfile "personal": ${corpCount} corp entr${corpCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
828
+ `machineProfile "personal": ${guardedCount} guarded entr${guardedCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
512
829
  );
513
830
  }
514
831
  }
515
832
  return { findings, disclosures };
516
833
  }
517
- async function checkCorpWallArtifacts(entries, findings) {
834
+ async function checkGuardedWallArtifacts(entries, findings) {
518
835
  for (const entry of entries) {
519
- if (entry.profile !== "corp" || !entry.resolvedRoot) continue;
520
- for (const artifact of CORP_WALL_ARTIFACTS) {
836
+ if (entry.profile !== "guarded" || !entry.resolvedRoot) continue;
837
+ for (const artifact of GUARDED_WALL_ARTIFACTS) {
521
838
  if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
522
839
  findings.push(
523
840
  finding(
524
- `${FLEET_LENS}/corp-artifact-in-repo:${entry.name}:${artifact}`,
841
+ `${FLEET_LENS}/guarded-artifact-in-repo:${entry.name}:${artifact}`,
525
842
  "risk",
526
- `corp entry \`${entry.name}\` carries ${artifact} inside its worktree`,
843
+ `guarded entry \`${entry.name}\` carries ${artifact} inside its worktree`,
527
844
  [`${entry.resolvedRoot}/${artifact}`],
528
- "Agent artifacts for corp repos live outside the repo by policy \u2014 an in-repo copy is one push away from colleagues' eyes and will diverge from the manifest-side truth.",
529
- `Move it to the fleet's corp/${entry.name}/ zone and leave a pointer if the harness needs one.`
845
+ "Agent artifacts for guarded repos live outside the repo by policy \u2014 an in-repo copy is one push away from colleagues' eyes and will diverge from the manifest-side truth.",
846
+ `Move it to the fleet's guarded/${entry.name}/ zone and leave a pointer if the harness needs one.`
530
847
  )
531
848
  );
532
849
  }
@@ -534,9 +851,9 @@ async function checkCorpWallArtifacts(entries, findings) {
534
851
  }
535
852
  }
536
853
  async function checkCoverage(manifest, findings, disclosures) {
537
- if (!manifest.corpHosts.length) {
854
+ if (!manifest.guardedHosts.length) {
538
855
  disclosures.push(
539
- "Coverage check skipped \u2014 no corpHosts in the local manifest, so a corp remote cannot be recognized."
856
+ "Coverage check skipped \u2014 no guardedHosts in the local manifest, so a guarded remote cannot be recognized."
540
857
  );
541
858
  return;
542
859
  }
@@ -557,16 +874,16 @@ async function checkCoverage(manifest, findings, disclosures) {
557
874
  if (!await pathExists(path.join(dir, ".git"))) continue;
558
875
  const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
559
876
  if (!remotes) continue;
560
- const corpRemote = manifest.corpHosts.find((h) => remotes.includes(h));
561
- if (corpRemote) {
877
+ const guardedRemote = manifest.guardedHosts.find((h) => remotes.includes(h));
878
+ if (guardedRemote) {
562
879
  findings.push(
563
880
  finding(
564
- `${FLEET_LENS}/unregistered-corp-remote:${d.name}`,
881
+ `${FLEET_LENS}/unregistered-guarded-remote:${d.name}`,
565
882
  "risk",
566
- `\`${d.name}\` under the fleet root has a corp remote but is not registered`,
567
- [`${dir}: remote matches ${corpRemote}`],
568
- "The wall is only as good as its census \u2014 an unregistered employer checkout is corp material no sweep, wall check, or transfer plan knows exists.",
569
- "Register it as a corp entry (opaque alias + local dirs mapping) or move it out of the fleet root."
883
+ `\`${d.name}\` under the fleet root has a guarded remote but is not registered`,
884
+ [`${dir}: remote matches ${guardedRemote}`],
885
+ "The wall is only as good as its census \u2014 an unregistered guarded checkout is guarded material no sweep, wall check, or transfer plan knows exists.",
886
+ "Register it as a guarded entry (opaque alias + local dirs mapping) or move it out of the fleet root."
570
887
  )
571
888
  );
572
889
  }
@@ -614,7 +931,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
614
931
  [
615
932
  ...Object.values(manifest.labels),
616
933
  ...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
617
- ...manifest.corpHosts
934
+ ...manifest.guardedHosts
618
935
  ].filter((n) => n.length >= 3)
619
936
  )
620
937
  ];
@@ -641,7 +958,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
641
958
  "risk",
642
959
  `${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
643
960
  [`${file}: contains "${needle}"`],
644
- "A public repo that names a private label, corp directory, or corp host joins the public and employer identities for anyone who greps.",
961
+ "A public repo that names a private label, guarded directory, or guarded host joins the public and guarded identities for anyone who greps.",
645
962
  "Scrub the needle from the tracked file (rewrite history if it already shipped)."
646
963
  )
647
964
  );
@@ -652,10 +969,10 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
652
969
  function expandNeedleDir(dir) {
653
970
  return dir.replace(/\/+$/, "");
654
971
  }
655
- async function checkCorpEmails(manifest, findings, disclosures) {
656
- if (!manifest.corpHosts.length) {
972
+ async function checkGuardedEmails(manifest, findings, disclosures) {
973
+ if (!manifest.guardedHosts.length) {
657
974
  disclosures.push(
658
- "Corp-email check skipped \u2014 no corpHosts in the local manifest, so a corp domain cannot be recognized."
975
+ "Guarded-email check skipped \u2014 no guardedHosts in the local manifest, so a guarded domain cannot be recognized."
659
976
  );
660
977
  return;
661
978
  }
@@ -664,21 +981,23 @@ async function checkCorpEmails(manifest, findings, disclosures) {
664
981
  const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
665
982
  if (emails === null) {
666
983
  disclosures.push(
667
- `Corp-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
984
+ `Guarded-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
668
985
  );
669
986
  continue;
670
987
  }
671
988
  const lines = emails.split("\n").filter(Boolean);
672
- const matched = [...new Set(lines.filter((e) => emailMatchesCorpHosts(e, manifest.corpHosts)))];
989
+ const matched = [
990
+ ...new Set(lines.filter((e) => emailMatchesGuardedHosts(e, manifest.guardedHosts)))
991
+ ];
673
992
  if (matched.length) {
674
993
  const count = lines.filter((e) => matched.includes(e)).length;
675
994
  findings.push(
676
995
  finding(
677
- `${FLEET_LENS}/corp-email:${entry.name}`,
996
+ `${FLEET_LENS}/guarded-email:${entry.name}`,
678
997
  "risk",
679
- `recent commits on personal entry \`${entry.name}\` are authored with a corp-host email`,
998
+ `recent commits on personal entry \`${entry.name}\` are authored with a guarded-host email`,
680
999
  [`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
681
- "A corp email in a personal repo's history ties the two identities together permanently \u2014 and silently, since the machine's global git identity is usually the corp one.",
1000
+ "A guarded email in a personal repo's history ties the two identities together permanently \u2014 and silently, since the machine's global git identity is usually the guarded one.",
682
1001
  "Set the repo-local identity to the personal one and amend the unpushed commits."
683
1002
  )
684
1003
  );
@@ -686,18 +1005,18 @@ async function checkCorpEmails(manifest, findings, disclosures) {
686
1005
  }
687
1006
  }
688
1007
  async function checkGateDrift(manifest, findings, disclosures) {
689
- const { planWorkflow } = await import('./generate-KEX75XNG.js');
690
- const { scanProject: scanProject2 } = await import('./scan-W4US23MU.js');
1008
+ const { planWorkflow } = await import('./generate-FSJQFQPV.js');
1009
+ const { scanProject } = await import('./scan-5SC47FSM.js');
691
1010
  const { readConfig } = await import('./config-724Y3IOB.js');
692
1011
  for (const entry of manifest.entries) {
693
1012
  const root = entry.resolvedRoot;
694
1013
  if (!root || !await isDirectory(root)) continue;
695
- if (entry.profile === "corp" || entry.private) continue;
1014
+ if (entry.profile === "guarded" || entry.private) continue;
696
1015
  if (entry.gates === "none") {
697
1016
  disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
698
1017
  continue;
699
1018
  }
700
- const facts = await scanProject2(root);
1019
+ const facts = await scanProject(root);
701
1020
  if (!facts.git.isRepo) continue;
702
1021
  if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
703
1022
  disclosures.push(
@@ -705,8 +1024,13 @@ async function checkGateDrift(manifest, findings, disclosures) {
705
1024
  );
706
1025
  continue;
707
1026
  }
708
- const { config } = await readConfig(root);
709
- const planned = (await planWorkflow(root, facts, { agents: false, gates: true, gateConfig: config.gates })).filter((f) => f.executable);
1027
+ const { config, explicit } = await readConfig(root);
1028
+ const planned = (await planWorkflow(root, facts, {
1029
+ agents: false,
1030
+ gates: true,
1031
+ gateConfig: config.gates,
1032
+ gateFailOnPinned: explicit.gatesFailOn
1033
+ })).filter((f) => f.executable);
710
1034
  const missing = planned.filter((f) => !f.exists).map((f) => f.path);
711
1035
  const differing = planned.filter((f) => f.exists && f.differs);
712
1036
  const stale = differing.filter((f) => f.drift !== "edited").map((f) => f.path);
@@ -745,11 +1069,11 @@ async function checkGateDrift(manifest, findings, disclosures) {
745
1069
  async function collectWallFindings(manifest) {
746
1070
  const findings = [];
747
1071
  const disclosures = [];
748
- await checkCorpWallArtifacts(manifest.entries, findings);
1072
+ await checkGuardedWallArtifacts(manifest.entries, findings);
749
1073
  await checkCoverage(manifest, findings, disclosures);
750
1074
  await checkManifestRepoMachinePaths(manifest, findings, disclosures);
751
1075
  await checkHygieneNeedles(manifest, findings, disclosures);
752
- await checkCorpEmails(manifest, findings, disclosures);
1076
+ await checkGuardedEmails(manifest, findings, disclosures);
753
1077
  await checkGateDrift(manifest, findings, disclosures);
754
1078
  return { findings, disclosures };
755
1079
  }
@@ -815,13 +1139,13 @@ async function sweepEntry(manifest, entry, opts) {
815
1139
  );
816
1140
  }
817
1141
  }
818
- const corp = entry.profile === "corp";
819
- const optedIn = !corp && await pathExists(path.join(root, ETYMD_DIR));
1142
+ const guarded = entry.profile === "guarded";
1143
+ const optedIn = !guarded && await pathExists(path.join(root, ETYMD_DIR));
820
1144
  const result = await runAudit(root, {
821
1145
  kind: opts.kind,
822
- persistLedger: !corp && opts.persistLedgers === true && optedIn,
823
- readOnlyRoot: corp,
824
- ledgerRoot: corp ? corpPersistenceRoot(manifest, entry.name) : void 0,
1146
+ persistLedger: !guarded && opts.persistLedgers === true && optedIn,
1147
+ readOnlyRoot: guarded,
1148
+ ledgerRoot: guarded ? guardedPersistenceRoot(manifest, entry.name) : void 0,
825
1149
  stateBudgets: stateBudgetsFor(entry),
826
1150
  upstreamRemote
827
1151
  });
@@ -829,11 +1153,43 @@ async function sweepEntry(manifest, entry, opts) {
829
1153
  if (entry.contract.placement === "none") {
830
1154
  project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
831
1155
  }
1156
+ const milestonesFile = entry.contract.milestones;
1157
+ if (milestonesFile && milestonesFile !== "none") {
1158
+ const text = await readText(path.join(root, milestonesFile));
1159
+ if (text === null) {
1160
+ project.findings.push(
1161
+ finding(
1162
+ `${FLEET_LENS}/milestones-missing:${entry.name}`,
1163
+ "gap",
1164
+ `\`${entry.name}\` declares milestones at \`${milestonesFile}\` and the file is absent`,
1165
+ [
1166
+ `${path.basename(manifest.manifestPath)}: ${entry.name}.contract.milestones = ${milestonesFile}`
1167
+ ],
1168
+ "The fleet board rolls every project's plan up from this file; a declared file that is absent is a row the board cannot fill.",
1169
+ 'Create it (heading `# Milestones`, then the standard table), or declare `milestones: "none"` if this project deliberately carries no plan.'
1170
+ )
1171
+ );
1172
+ } else {
1173
+ const doc = parseMilestones(text);
1174
+ doc.problems.forEach((problem, i) => {
1175
+ project.findings.push(
1176
+ finding(
1177
+ `${FLEET_LENS}/milestones-shape:${entry.name}:${i + 1}`,
1178
+ "gap",
1179
+ `\`${entry.name}\` ${milestonesFile}: ${problem}`,
1180
+ [`${milestonesFile}`],
1181
+ "A milestones file the board cannot parse is a plan the fleet cannot see.",
1182
+ `Fix the table to the standard shape: \`| ${MILESTONE_COLUMNS.join(" | ")} |\`.`
1183
+ )
1184
+ );
1185
+ });
1186
+ }
1187
+ }
832
1188
  for (const f of project.findings) project.counts[f.tier] += 1;
833
1189
  project.stateAgeDays = stateAgeDays(result.facts);
834
1190
  project.staleAfterDays = result.config.config.state.staleAfterDays;
835
1191
  for (const [key, value] of Object.entries(entry.contract)) {
836
- if (key === "placement" || !value) continue;
1192
+ if (key === "placement" || key === "milestones" || !value) continue;
837
1193
  const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
838
1194
  project.disclosures.push(
839
1195
  `contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
@@ -885,12 +1241,12 @@ async function fleetLedgerTarget(manifest, name) {
885
1241
  if (!entry.resolvedRoot) {
886
1242
  throw new Error(`\`${name}\` does not resolve on this machine: ${entry.unresolved}`);
887
1243
  }
888
- const corp = entry.profile === "corp";
1244
+ const guarded = entry.profile === "guarded";
889
1245
  return {
890
1246
  entry,
891
1247
  auditRoot: entry.resolvedRoot,
892
- ledgerRoot: corp ? corpPersistenceRoot(manifest, name) : entry.resolvedRoot,
893
- corp
1248
+ ledgerRoot: guarded ? guardedPersistenceRoot(manifest, name) : entry.resolvedRoot,
1249
+ guarded
894
1250
  };
895
1251
  }
896
1252
  async function ensureFindingRecorded(manifest, name, id) {
@@ -899,398 +1255,11 @@ async function ensureFindingRecorded(manifest, name, id) {
899
1255
  if (await has()) return { ledgerRoot: target.ledgerRoot, recorded: true };
900
1256
  await runAudit(target.auditRoot, {
901
1257
  persistLedger: true,
902
- readOnlyRoot: target.corp,
903
- ledgerRoot: target.corp ? target.ledgerRoot : void 0,
1258
+ readOnlyRoot: target.guarded,
1259
+ ledgerRoot: target.guarded ? target.ledgerRoot : void 0,
904
1260
  stateBudgets: stateBudgetsFor(target.entry)
905
1261
  });
906
1262
  return { ledgerRoot: target.ledgerRoot, recorded: await has() };
907
1263
  }
908
1264
 
909
- // src/commands/fleet.ts
910
- var LAST_SWEEP_FILE = "last.fleet.json";
911
- async function loadManifest(cwd, manifestOpt) {
912
- if (manifestOpt) {
913
- return { manifest: await loadFleetManifest(path.resolve(cwd, manifestOpt)) };
914
- }
915
- const candidate = path.join(cwd, "registry.json");
916
- if (await pathExists(candidate)) {
917
- return {
918
- manifest: await loadFleetManifest(candidate),
919
- autoNote: `manifest auto-selected from the cwd: ${candidate} (no --manifest given)`
920
- };
921
- }
922
- throw new Error(
923
- "no --manifest given and no registry.json in the current directory \u2014 pass --manifest <file> (there is deliberately no env var and no global pointer)"
924
- );
925
- }
926
- async function readLastSweep(lastPath) {
927
- const raw = await readText(lastPath);
928
- if (raw === null) return { record: null };
929
- let parsed;
930
- try {
931
- parsed = JSON.parse(raw);
932
- } catch {
933
- return {
934
- record: null,
935
- note: `${LAST_SWEEP_FILE} exists but is not valid JSON \u2014 delta baseline reset (every finding reads as new this sweep).`
936
- };
937
- }
938
- const rec = parsed;
939
- if (typeof rec !== "object" || rec === null || rec.schema !== FLEET_JSON_SCHEMA || typeof rec.projects !== "object" || rec.projects === null || Array.isArray(rec.projects) || !Array.isArray(rec.wall)) {
940
- return {
941
- record: null,
942
- note: `${LAST_SWEEP_FILE} carries a foreign schema \u2014 delta baseline reset (every finding reads as new this sweep).`
943
- };
944
- }
945
- return { record: rec };
946
- }
947
- function failOnExit(failOn, findings) {
948
- if (failOn === void 0) return;
949
- if (meetsFailOn(findings, parseFailOnTier(failOn))) process.exitCode = 1;
950
- }
951
- async function sweep(opts) {
952
- if (opts.profile && opts.profile !== "personal" && opts.profile !== "corp") {
953
- throw new Error(`--profile must be personal or corp, got \`${opts.profile}\``);
954
- }
955
- if (opts.failOn !== void 0) parseFailOnTier(opts.failOn);
956
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
957
- const result = await sweepFleet(manifest, {
958
- only: opts.only,
959
- profile: opts.profile,
960
- kind: opts.truth ? "truth" : void 0,
961
- persistLedgers: opts.persistLedgers
962
- });
963
- const lastPath = path.join(manifest.dir, LAST_SWEEP_FILE);
964
- const { record: previous, note: baselineNote } = await readLastSweep(lastPath);
965
- const previousIds = (name) => new Set((previous?.projects[name] ?? []).map((e) => e.id));
966
- const previousWallIds = new Set((previous?.wall ?? []).map((e) => e.id));
967
- const allFindings = [...result.projects.flatMap((p) => p.findings), ...result.wall];
968
- failOnExit(opts.failOn, allFindings);
969
- const fullSweep = !opts.only && !opts.profile && !opts.truth;
970
- const delta = {};
971
- for (const p of result.projects) {
972
- const prev = previousIds(p.name);
973
- const current = new Set(p.findings.map((f) => f.id));
974
- delta[p.name] = {
975
- new: [...current].filter((id) => !prev.has(id)),
976
- resolved: [...prev].filter((id) => !current.has(id))
977
- };
978
- }
979
- const wallNew = result.wall.filter((f) => !previousWallIds.has(f.id)).map((f) => f.id);
980
- if (opts.json) {
981
- print(
982
- JSON.stringify(
983
- {
984
- ...result,
985
- delta: previous ? delta : null,
986
- ...baselineNote ? { deltaBaselineNote: baselineNote } : {}
987
- },
988
- null,
989
- 2
990
- )
991
- );
992
- } else {
993
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
994
- if (baselineNote) print(` ${theme.dim(`\u25E6 ${baselineNote}`)}`);
995
- renderSweep(result, previous, delta, wallNew);
996
- }
997
- if (fullSweep) {
998
- const record = {
999
- schema: FLEET_JSON_SCHEMA,
1000
- sweptAt: result.sweptAt,
1001
- projects: Object.fromEntries(
1002
- result.projects.map((p) => [p.name, p.findings.map((f) => ({ id: f.id, tier: f.tier }))])
1003
- ),
1004
- wall: result.wall.map((f) => ({ id: f.id, tier: f.tier }))
1005
- };
1006
- const tmpPath = `${lastPath}.tmp`;
1007
- await promises.writeFile(tmpPath, JSON.stringify(record, null, 2) + "\n", "utf8");
1008
- await promises.rename(tmpPath, lastPath);
1009
- } else if (!opts.json) {
1010
- print();
1011
- print(` ${theme.dim("partial sweep \u2014 the delta baseline was not updated")}`);
1012
- }
1013
- }
1014
- function renderSweep(result, previous, delta, wallNew) {
1015
- section(
1016
- `Fleet sweep ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 ${result.projects.length} project(s) \xB7 schema ${result.schema}`)}`
1017
- );
1018
- const rows = result.projects.map((p) => {
1019
- const d = delta[p.name] ?? { new: [], resolved: [] };
1020
- const deltaStr = !previous ? "first sweep" : d.new.length || d.resolved.length ? `\u0394 ${[d.new.length ? `+${d.new.length}` : "", d.resolved.length ? `\u2212${d.resolved.length}` : ""].filter(Boolean).join(" ")}` : "\u0394 \xB10";
1021
- return {
1022
- name: p.name,
1023
- age: p.stateAgeDays === null ? "\u2014" : `${p.stateAgeDays}/${p.staleAfterDays}d`,
1024
- counts: p.counts,
1025
- delta: deltaStr,
1026
- note: p.unresolved
1027
- };
1028
- });
1029
- renderFleetRows(rows);
1030
- renderFleetNotes(
1031
- result.problems,
1032
- result.projects.flatMap((p) => p.disclosures.map((d) => `${p.name}: ${d}`))
1033
- );
1034
- const detail = [
1035
- ...result.projects.flatMap(
1036
- (p) => p.findings.filter((f) => f.tier === "risk" || (delta[p.name]?.new ?? []).includes(f.id))
1037
- ),
1038
- ...result.wall.filter((f) => f.tier === "risk" || wallNew.includes(f.id))
1039
- ];
1040
- if (detail.length) {
1041
- section(`New or risk findings ${theme.dim(`(${detail.length})`)}`);
1042
- renderFindings(detail);
1043
- }
1044
- if (result.recurringClasses.length) {
1045
- section(`Recurring classes ${theme.dim("(open in \u22652 projects \u2014 class-fix candidates)")}`);
1046
- for (const rc of result.recurringClasses) {
1047
- print(
1048
- ` ${TIER_BADGE[rc.tier]} ${rc.classId} ${theme.dim(`\u2014 ${rc.projects.length}\xD7: ${rc.projects.join(", ")}`)}`
1049
- );
1050
- }
1051
- }
1052
- section("Fleet wall");
1053
- if (!result.wall.length) print(` ${glyph.ok} ${theme.dim("clean")}`);
1054
- else
1055
- print(
1056
- ` ${theme.warn(`${result.wall.length} finding(s)`)} ${theme.dim("(detailed above if new or risk)")}`
1057
- );
1058
- renderFleetNotes([], result.wallDisclosures);
1059
- if (result.outOfScope.length) {
1060
- print(
1061
- ` ${theme.dim(`out of scope (not audited, named above): ${result.outOfScope.join(", ")}`)}`
1062
- );
1063
- }
1064
- }
1065
- async function check(opts) {
1066
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1067
- const { findings, disclosures } = await checkManifest(manifest);
1068
- if (findings.length) process.exitCode = 1;
1069
- if (opts.json) {
1070
- print(
1071
- JSON.stringify(
1072
- { schema: FLEET_JSON_SCHEMA, manifest: manifest.manifestPath, findings, disclosures },
1073
- null,
1074
- 2
1075
- )
1076
- );
1077
- return;
1078
- }
1079
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1080
- section(
1081
- `Fleet check ${theme.dim(`\xB7 ${path.basename(manifest.manifestPath)} \xB7 ${manifest.entries.length} entr${manifest.entries.length === 1 ? "y" : "ies"} \xB7 manifest truth only, no lenses`)}`
1082
- );
1083
- renderFindings(findings);
1084
- renderFleetNotes([], disclosures);
1085
- }
1086
- async function recordCorpMapping(manifest, name, absTarget) {
1087
- const home = os.homedir();
1088
- const value = absTarget === home || absTarget.startsWith(home + path.sep) ? `~${absTarget.slice(home.length)}` : absTarget;
1089
- const insideRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
1090
- if (insideRepo) {
1091
- const ignored = await git(manifest.dir, ["check-ignore", "-q", manifest.localPath]) !== null;
1092
- if (!ignored) {
1093
- throw new Error(
1094
- `${path.basename(manifest.localPath)} is not gitignored \u2014 refusing to write employer directory names into a file git would track. Add it to .gitignore, then re-run.`
1095
- );
1096
- }
1097
- }
1098
- const existing = await readText(manifest.localPath);
1099
- let doc = {};
1100
- if (existing) {
1101
- try {
1102
- doc = JSON.parse(existing);
1103
- } catch {
1104
- throw new Error(
1105
- `${path.basename(manifest.localPath)} is not valid JSON \u2014 fix it before registering, so the mapping is not written over hand-maintained entries that cannot be re-derived.`
1106
- );
1107
- }
1108
- }
1109
- const dirs = doc.dirs ?? {};
1110
- dirs[name] = value;
1111
- doc.dirs = dirs;
1112
- await promises.writeFile(manifest.localPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
1113
- return value;
1114
- }
1115
- async function add(opts) {
1116
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1117
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1118
- if (manifest.shape !== "registry") {
1119
- throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
1120
- }
1121
- const raw = await readText(manifest.manifestPath);
1122
- if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
1123
- const absTarget = path.resolve(opts.cwd, opts.target);
1124
- if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
1125
- const name = opts.name ?? path.basename(absTarget);
1126
- if (manifest.entries.some((e) => e.name === name)) {
1127
- throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
1128
- }
1129
- const facts = await scanProject(absTarget);
1130
- const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
1131
- section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
1132
- let profile = opts.profile;
1133
- if (!profile && !opts.yes) {
1134
- const picked = await select({
1135
- message: "Which side of the wall does this belong to?",
1136
- initialValue: "personal",
1137
- options: [
1138
- { value: "personal", label: "personal \u2014 your own work" },
1139
- { value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
1140
- ]
1141
- });
1142
- if (isCancel(picked)) {
1143
- cancel("No entry written.");
1144
- return;
1145
- }
1146
- profile = picked;
1147
- }
1148
- profile = profile ?? "personal";
1149
- if (profile !== "personal" && profile !== "corp") {
1150
- throw new Error(`--profile must be personal or corp, got \`${profile}\``);
1151
- }
1152
- const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
1153
- let kind = opts.kind;
1154
- if (!kind && !opts.yes) {
1155
- const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
1156
- const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
1157
- const picked = await select({
1158
- message: "Kind?",
1159
- initialValue: derivedKind,
1160
- options: [
1161
- ...choices.map((k) => ({
1162
- value: k,
1163
- label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
1164
- })),
1165
- { value: "", label: "something else\u2026" }
1166
- ]
1167
- });
1168
- if (isCancel(picked)) {
1169
- cancel("No entry written.");
1170
- return;
1171
- }
1172
- kind = picked;
1173
- if (!kind) {
1174
- const typed = await text({
1175
- message: "Kind (free text):",
1176
- validate: (v) => v.trim() ? void 0 : "a kind is required"
1177
- });
1178
- if (isCancel(typed)) {
1179
- cancel("No entry written.");
1180
- return;
1181
- }
1182
- kind = typed.trim();
1183
- }
1184
- }
1185
- kind = kind ?? derivedKind;
1186
- let trust;
1187
- if (profile === "personal") {
1188
- trust = opts.trust;
1189
- if (!trust && !opts.yes) {
1190
- const picked = await select({
1191
- message: "How exposed is this repo's history? (gates content screening)",
1192
- options: [
1193
- { value: "private", label: "private \u2014 not destined to be published" },
1194
- { value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
1195
- { value: "public-repo", label: "public-repo \u2014 already public" }
1196
- ]
1197
- });
1198
- if (isCancel(picked)) {
1199
- cancel("No entry written.");
1200
- return;
1201
- }
1202
- trust = picked;
1203
- }
1204
- if (!trust) {
1205
- throw new Error(
1206
- `\`${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)`
1207
- );
1208
- }
1209
- if (!isFleetTrust(trust)) {
1210
- throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
1211
- }
1212
- } else if (opts.trust) {
1213
- throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
1214
- }
1215
- const corpHost = remote ? manifest.corpHosts.find((h) => remote.includes(h)) : void 0;
1216
- if (corpHost && profile !== "corp") {
1217
- throw new Error(
1218
- `\`${name}\` has a remote on the corp host \`${corpHost}\`, but --profile is \`${profile}\`. A personal entry records its path and raw remote in the TRACKED manifest \u2014 register it with \`--profile corp\` (alias-only, machine-pinned), or move it out of the fleet root.`
1219
- );
1220
- }
1221
- const entry = profile === "corp" ? { name, kind, profile, private: true } : {
1222
- name,
1223
- kind,
1224
- profile,
1225
- path: path.relative(manifest.root ?? manifest.dir, absTarget),
1226
- trust,
1227
- contract: {},
1228
- links: {}
1229
- // The remote is deliberately NOT recorded. Nothing reads it — it was persisted only
1230
- // because it happened to be derivable — and it is the field that turns a mis-profiled
1231
- // entry into a disclosure: a raw URL carries the host AND the internal group path,
1232
- // while `path` carries a bare directory name. Removing a field no consumer reads
1233
- // retires that whole class without any host-matching guess, and keeps working on a
1234
- // machine that has no local manifest for the corp-host guard above to read.
1235
- // It stays derivable at any time from the checkout itself, which is where it came from.
1236
- };
1237
- print(` ${glyph.bullet} ${theme.dim("entry")}`);
1238
- print(
1239
- JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
1240
- );
1241
- if (!opts.yes) {
1242
- const ok = await confirm({
1243
- message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
1244
- });
1245
- if (isCancel(ok) || !ok) {
1246
- cancel("No entry written.");
1247
- return;
1248
- }
1249
- }
1250
- let mappedTo;
1251
- if (profile === "corp") {
1252
- mappedTo = await recordCorpMapping(manifest, name, absTarget);
1253
- }
1254
- const doc = JSON.parse(raw);
1255
- if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
1256
- doc.projects.push(entry);
1257
- await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
1258
- print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
1259
- if (mappedTo) {
1260
- print(
1261
- ` ${glyph.ok} ${theme.dim("mapped")} ${theme.info(`${name} \u2192 ${mappedTo}`)} ${theme.dim(`in ${path.basename(manifest.localPath)} (gitignored)`)}`
1262
- );
1263
- }
1264
- print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
1265
- }
1266
- async function resolveFleetFinding(opts, status) {
1267
- if (status === "dismissed" && !opts.reason?.trim()) {
1268
- throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
1269
- }
1270
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1271
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1272
- const target = await fleetLedgerTarget(manifest, opts.name);
1273
- const hadLedger = await pathExists(path.join(target.ledgerRoot, ".etymd"));
1274
- const { ledgerRoot, recorded } = await ensureFindingRecorded(manifest, opts.name, opts.id);
1275
- if (!recorded) {
1276
- throw new Error(
1277
- `no finding \`${opts.id}\` recorded for \`${opts.name}\` even after a fresh audit \u2014 check the id against \`etymd fleet\` output (fleet-manifest wall findings are not ledger-quietable)`
1278
- );
1279
- }
1280
- if (!hadLedger) {
1281
- print(
1282
- ` ${theme.dim(`first ledger for \`${opts.name}\`: created ${path.join(ledgerRoot, ".etymd")} (a persisting audit ran to record the finding)`)}`
1283
- );
1284
- }
1285
- const args = { cwd: ledgerRoot, id: opts.id, reason: opts.reason };
1286
- await (status === "dismissed" ? dismiss(args) : accept(args));
1287
- print(` ${theme.dim(`ledger: ${path.join(ledgerRoot, ".etymd", "ledger.json")}`)}`);
1288
- }
1289
- async function dismiss2(opts) {
1290
- await resolveFleetFinding(opts, "dismissed");
1291
- }
1292
- async function accept2(opts) {
1293
- await resolveFleetFinding(opts, "accepted");
1294
- }
1295
-
1296
- export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };
1265
+ export { BOARD_JSON_SCHEMA, FLEET_JSON_SCHEMA, FLEET_TRUST_VALUES, MILESTONES_FILE, checkManifest, ensureFindingRecorded, fleetLedgerTarget, isFleetTrust, loadFleetManifest, parseInitiatives, parseMilestones, renderBoard, sweepFleet };