etymd 0.14.0 → 0.16.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,18 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { dismiss, accept } from './chunk-HOR4M6EC.js';
3
- import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-LT7JMVV3.js';
2
+ import { runAudit } from './chunk-UV3YIBDW.js';
4
3
  import { readLedger } from './chunk-3E2IPCRY.js';
5
- import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
6
- import { scanProject } from './chunk-HWC6PKJM.js';
7
- import './chunk-623W6KVC.js';
8
4
  import { DEFAULT_CONFIG, ETYMD_DIR } from './chunk-P6ATKV2R.js';
9
- import './chunk-YQZDYDAK.js';
10
- import { readText, pathExists, git, isDirectory } from './chunk-4VPBP6K6.js';
11
- import { promises } from 'fs';
5
+ import { readText, isDirectory, git, pathExists } from './chunk-4VPBP6K6.js';
12
6
  import os from 'os';
13
7
  import path from 'path';
14
- import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
15
8
  import { execFile } from 'child_process';
9
+ import { promises } from 'fs';
16
10
  import { promisify } from 'util';
17
11
 
18
12
  var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
@@ -24,14 +18,14 @@ function expandTilde(p) {
24
18
  if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
25
19
  return p;
26
20
  }
27
- 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"}}';
28
22
  var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
29
23
  function rejectUnsafeName(name, where, manifest, manifestFile) {
30
24
  if (SAFE_NAME.test(name)) return false;
31
25
  manifest.problems.push({
32
26
  kind: "bad-shape",
33
27
  file: manifestFile,
34
- 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)`
35
29
  });
36
30
  return true;
37
31
  }
@@ -72,7 +66,7 @@ async function readLocal(localPath, problems) {
72
66
  }
73
67
  }
74
68
  function loadRegistryEntries(projects, manifest, manifestFile) {
75
- const corpNames = [];
69
+ const guardedNames = [];
76
70
  for (const [i, rawEntry] of projects.entries()) {
77
71
  const rec = asRecord(rawEntry);
78
72
  const name = rec ? asString(rec.name) : void 0;
@@ -85,8 +79,8 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
85
79
  continue;
86
80
  }
87
81
  if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
88
- const profile = rec.profile === "corp" ? "corp" : "personal";
89
- 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") {
90
84
  manifest.problems.push({
91
85
  kind: "bad-shape",
92
86
  file: manifestFile,
@@ -124,12 +118,12 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
124
118
  contract,
125
119
  links: asStringMap(rec.links)
126
120
  };
127
- if (entry.profile === "corp" || entry.private) {
128
- corpNames.push(name);
121
+ if (entry.profile === "guarded" || entry.private) {
122
+ guardedNames.push(name);
129
123
  if (!manifest.localPresent) {
130
124
  entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
131
125
  } else if (manifest.machineProfile === "personal") {
132
- 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';
133
127
  } else {
134
128
  const dir = manifest.localDirs[name];
135
129
  if (!dir) {
@@ -145,11 +139,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
145
139
  }
146
140
  manifest.entries.push(entry);
147
141
  }
148
- if (corpNames.length && !manifest.localPresent) {
142
+ if (guardedNames.length && !manifest.localPresent) {
149
143
  manifest.problems.push({
150
144
  kind: "local-missing",
151
145
  file: path.basename(manifest.localPath),
152
- 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}`
153
147
  });
154
148
  }
155
149
  }
@@ -198,7 +192,7 @@ async function loadFleetManifest(manifestPath) {
198
192
  shape: "registry",
199
193
  manifestPath: abs,
200
194
  dir,
201
- corpHosts: [],
195
+ guardedHosts: [],
202
196
  labels: {},
203
197
  localDirs: {},
204
198
  localPath: path.join(dir, "registry.local.json"),
@@ -231,8 +225,8 @@ async function loadFleetManifest(manifestPath) {
231
225
  manifest.localPresent = local.present;
232
226
  manifest.localDirs = asStringMap(local.data.dirs);
233
227
  manifest.labels = asStringMap(local.data.labels);
234
- manifest.corpHosts = Array.isArray(local.data.corpHosts) ? local.data.corpHosts.filter((h) => typeof h === "string") : [];
235
- 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;
236
230
  const rootRaw = asString(local.data.root) ?? asString(parsed.root);
237
231
  if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
238
232
  const orientationRec = asRecord(parsed.orientation);
@@ -270,10 +264,334 @@ async function loadFleetManifest(manifestPath) {
270
264
  }
271
265
  return manifest;
272
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
+ }
273
591
  var pExecFile = promisify(execFile);
274
592
  var FLEET_LENS = "fleet-manifest";
275
593
  var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
276
- var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
594
+ var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
277
595
  function recurringClasses(projects) {
278
596
  const byClass = /* @__PURE__ */ new Map();
279
597
  for (const p of projects) {
@@ -306,12 +624,12 @@ function finding(id, tier, claim, evidence, why, action) {
306
624
  confidence: "high"
307
625
  };
308
626
  }
309
- function corpPersistenceRoot(manifest, name) {
310
- const corpDir = path.join(manifest.dir, "corp");
311
- const root = path.resolve(corpDir, name);
312
- 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) {
313
631
  throw new Error(
314
- `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`
315
633
  );
316
634
  }
317
635
  return root;
@@ -344,7 +662,7 @@ async function realpathOr(p) {
344
662
  return path.resolve(p);
345
663
  }
346
664
  }
347
- function emailMatchesCorpHosts(email, hosts) {
665
+ function emailMatchesGuardedHosts(email, hosts) {
348
666
  const domain = email.split("@")[1]?.toLowerCase();
349
667
  if (!domain) return false;
350
668
  return hosts.some((h) => {
@@ -395,7 +713,7 @@ async function checkManifest(manifest) {
395
713
  );
396
714
  }
397
715
  seen.add(entry.name);
398
- if (entry.profile !== "corp" && !entry.private && !entry.trust) {
716
+ if (entry.profile !== "guarded" && !entry.private && !entry.trust) {
399
717
  const bad = entry.trustRaw;
400
718
  findings.push(
401
719
  finding(
@@ -433,14 +751,14 @@ async function checkManifest(manifest) {
433
751
  )
434
752
  );
435
753
  }
436
- 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)) {
437
755
  findings.push(
438
756
  finding(
439
757
  `${FLEET_LENS}/dangling-dir:${entry.name}`,
440
758
  "gap",
441
759
  `\`${entry.name}\` has no usable directory mapping`,
442
760
  [`${localFile}: ${entry.unresolved}`],
443
- "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.",
444
762
  `Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
445
763
  )
446
764
  );
@@ -504,28 +822,28 @@ async function checkManifest(manifest) {
504
822
  );
505
823
  }
506
824
  if (manifest.machineProfile === "personal") {
507
- const corpCount = manifest.entries.filter((e) => e.profile === "corp" || e.private).length;
508
- if (corpCount) {
825
+ const guardedCount = manifest.entries.filter((e) => e.profile === "guarded" || e.private).length;
826
+ if (guardedCount) {
509
827
  disclosures.push(
510
- `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.`
511
829
  );
512
830
  }
513
831
  }
514
832
  return { findings, disclosures };
515
833
  }
516
- async function checkCorpWallArtifacts(entries, findings) {
834
+ async function checkGuardedWallArtifacts(entries, findings) {
517
835
  for (const entry of entries) {
518
- if (entry.profile !== "corp" || !entry.resolvedRoot) continue;
519
- for (const artifact of CORP_WALL_ARTIFACTS) {
836
+ if (entry.profile !== "guarded" || !entry.resolvedRoot) continue;
837
+ for (const artifact of GUARDED_WALL_ARTIFACTS) {
520
838
  if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
521
839
  findings.push(
522
840
  finding(
523
- `${FLEET_LENS}/corp-artifact-in-repo:${entry.name}:${artifact}`,
841
+ `${FLEET_LENS}/guarded-artifact-in-repo:${entry.name}:${artifact}`,
524
842
  "risk",
525
- `corp entry \`${entry.name}\` carries ${artifact} inside its worktree`,
843
+ `guarded entry \`${entry.name}\` carries ${artifact} inside its worktree`,
526
844
  [`${entry.resolvedRoot}/${artifact}`],
527
- "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.",
528
- `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.`
529
847
  )
530
848
  );
531
849
  }
@@ -533,9 +851,9 @@ async function checkCorpWallArtifacts(entries, findings) {
533
851
  }
534
852
  }
535
853
  async function checkCoverage(manifest, findings, disclosures) {
536
- if (!manifest.corpHosts.length) {
854
+ if (!manifest.guardedHosts.length) {
537
855
  disclosures.push(
538
- "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."
539
857
  );
540
858
  return;
541
859
  }
@@ -556,16 +874,16 @@ async function checkCoverage(manifest, findings, disclosures) {
556
874
  if (!await pathExists(path.join(dir, ".git"))) continue;
557
875
  const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
558
876
  if (!remotes) continue;
559
- const corpRemote = manifest.corpHosts.find((h) => remotes.includes(h));
560
- if (corpRemote) {
877
+ const guardedRemote = manifest.guardedHosts.find((h) => remotes.includes(h));
878
+ if (guardedRemote) {
561
879
  findings.push(
562
880
  finding(
563
- `${FLEET_LENS}/unregistered-corp-remote:${d.name}`,
881
+ `${FLEET_LENS}/unregistered-guarded-remote:${d.name}`,
564
882
  "risk",
565
- `\`${d.name}\` under the fleet root has a corp remote but is not registered`,
566
- [`${dir}: remote matches ${corpRemote}`],
567
- "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.",
568
- "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."
569
887
  )
570
888
  );
571
889
  }
@@ -613,7 +931,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
613
931
  [
614
932
  ...Object.values(manifest.labels),
615
933
  ...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
616
- ...manifest.corpHosts
934
+ ...manifest.guardedHosts
617
935
  ].filter((n) => n.length >= 3)
618
936
  )
619
937
  ];
@@ -640,7 +958,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
640
958
  "risk",
641
959
  `${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
642
960
  [`${file}: contains "${needle}"`],
643
- "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.",
644
962
  "Scrub the needle from the tracked file (rewrite history if it already shipped)."
645
963
  )
646
964
  );
@@ -651,10 +969,10 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
651
969
  function expandNeedleDir(dir) {
652
970
  return dir.replace(/\/+$/, "");
653
971
  }
654
- async function checkCorpEmails(manifest, findings, disclosures) {
655
- if (!manifest.corpHosts.length) {
972
+ async function checkGuardedEmails(manifest, findings, disclosures) {
973
+ if (!manifest.guardedHosts.length) {
656
974
  disclosures.push(
657
- "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."
658
976
  );
659
977
  return;
660
978
  }
@@ -663,21 +981,23 @@ async function checkCorpEmails(manifest, findings, disclosures) {
663
981
  const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
664
982
  if (emails === null) {
665
983
  disclosures.push(
666
- `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.`
667
985
  );
668
986
  continue;
669
987
  }
670
988
  const lines = emails.split("\n").filter(Boolean);
671
- 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
+ ];
672
992
  if (matched.length) {
673
993
  const count = lines.filter((e) => matched.includes(e)).length;
674
994
  findings.push(
675
995
  finding(
676
- `${FLEET_LENS}/corp-email:${entry.name}`,
996
+ `${FLEET_LENS}/guarded-email:${entry.name}`,
677
997
  "risk",
678
- `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`,
679
999
  [`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
680
- "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.",
681
1001
  "Set the repo-local identity to the personal one and amend the unpushed commits."
682
1002
  )
683
1003
  );
@@ -685,18 +1005,18 @@ async function checkCorpEmails(manifest, findings, disclosures) {
685
1005
  }
686
1006
  }
687
1007
  async function checkGateDrift(manifest, findings, disclosures) {
688
- const { planWorkflow } = await import('./generate-KEX75XNG.js');
689
- const { scanProject: scanProject2 } = await import('./scan-QRTH6HAP.js');
1008
+ const { planWorkflow } = await import('./generate-YEARCSHW.js');
1009
+ const { scanProject } = await import('./scan-64Z6SNYF.js');
690
1010
  const { readConfig } = await import('./config-724Y3IOB.js');
691
1011
  for (const entry of manifest.entries) {
692
1012
  const root = entry.resolvedRoot;
693
1013
  if (!root || !await isDirectory(root)) continue;
694
- if (entry.profile === "corp" || entry.private) continue;
1014
+ if (entry.profile === "guarded" || entry.private) continue;
695
1015
  if (entry.gates === "none") {
696
1016
  disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
697
1017
  continue;
698
1018
  }
699
- const facts = await scanProject2(root);
1019
+ const facts = await scanProject(root);
700
1020
  if (!facts.git.isRepo) continue;
701
1021
  if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
702
1022
  disclosures.push(
@@ -744,11 +1064,11 @@ async function checkGateDrift(manifest, findings, disclosures) {
744
1064
  async function collectWallFindings(manifest) {
745
1065
  const findings = [];
746
1066
  const disclosures = [];
747
- await checkCorpWallArtifacts(manifest.entries, findings);
1067
+ await checkGuardedWallArtifacts(manifest.entries, findings);
748
1068
  await checkCoverage(manifest, findings, disclosures);
749
1069
  await checkManifestRepoMachinePaths(manifest, findings, disclosures);
750
1070
  await checkHygieneNeedles(manifest, findings, disclosures);
751
- await checkCorpEmails(manifest, findings, disclosures);
1071
+ await checkGuardedEmails(manifest, findings, disclosures);
752
1072
  await checkGateDrift(manifest, findings, disclosures);
753
1073
  return { findings, disclosures };
754
1074
  }
@@ -814,13 +1134,13 @@ async function sweepEntry(manifest, entry, opts) {
814
1134
  );
815
1135
  }
816
1136
  }
817
- const corp = entry.profile === "corp";
818
- const optedIn = !corp && await pathExists(path.join(root, ETYMD_DIR));
1137
+ const guarded = entry.profile === "guarded";
1138
+ const optedIn = !guarded && await pathExists(path.join(root, ETYMD_DIR));
819
1139
  const result = await runAudit(root, {
820
1140
  kind: opts.kind,
821
- persistLedger: !corp && opts.persistLedgers === true && optedIn,
822
- readOnlyRoot: corp,
823
- ledgerRoot: corp ? corpPersistenceRoot(manifest, entry.name) : void 0,
1141
+ persistLedger: !guarded && opts.persistLedgers === true && optedIn,
1142
+ readOnlyRoot: guarded,
1143
+ ledgerRoot: guarded ? guardedPersistenceRoot(manifest, entry.name) : void 0,
824
1144
  stateBudgets: stateBudgetsFor(entry),
825
1145
  upstreamRemote
826
1146
  });
@@ -828,11 +1148,43 @@ async function sweepEntry(manifest, entry, opts) {
828
1148
  if (entry.contract.placement === "none") {
829
1149
  project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
830
1150
  }
1151
+ const milestonesFile = entry.contract.milestones;
1152
+ if (milestonesFile && milestonesFile !== "none") {
1153
+ const text = await readText(path.join(root, milestonesFile));
1154
+ if (text === null) {
1155
+ project.findings.push(
1156
+ finding(
1157
+ `${FLEET_LENS}/milestones-missing:${entry.name}`,
1158
+ "gap",
1159
+ `\`${entry.name}\` declares milestones at \`${milestonesFile}\` and the file is absent`,
1160
+ [
1161
+ `${path.basename(manifest.manifestPath)}: ${entry.name}.contract.milestones = ${milestonesFile}`
1162
+ ],
1163
+ "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.",
1164
+ 'Create it (heading `# Milestones`, then the standard table), or declare `milestones: "none"` if this project deliberately carries no plan.'
1165
+ )
1166
+ );
1167
+ } else {
1168
+ const doc = parseMilestones(text);
1169
+ doc.problems.forEach((problem, i) => {
1170
+ project.findings.push(
1171
+ finding(
1172
+ `${FLEET_LENS}/milestones-shape:${entry.name}:${i + 1}`,
1173
+ "gap",
1174
+ `\`${entry.name}\` ${milestonesFile}: ${problem}`,
1175
+ [`${milestonesFile}`],
1176
+ "A milestones file the board cannot parse is a plan the fleet cannot see.",
1177
+ `Fix the table to the standard shape: \`| ${MILESTONE_COLUMNS.join(" | ")} |\`.`
1178
+ )
1179
+ );
1180
+ });
1181
+ }
1182
+ }
831
1183
  for (const f of project.findings) project.counts[f.tier] += 1;
832
1184
  project.stateAgeDays = stateAgeDays(result.facts);
833
1185
  project.staleAfterDays = result.config.config.state.staleAfterDays;
834
1186
  for (const [key, value] of Object.entries(entry.contract)) {
835
- if (key === "placement" || !value) continue;
1187
+ if (key === "placement" || key === "milestones" || !value) continue;
836
1188
  const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
837
1189
  project.disclosures.push(
838
1190
  `contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
@@ -884,12 +1236,12 @@ async function fleetLedgerTarget(manifest, name) {
884
1236
  if (!entry.resolvedRoot) {
885
1237
  throw new Error(`\`${name}\` does not resolve on this machine: ${entry.unresolved}`);
886
1238
  }
887
- const corp = entry.profile === "corp";
1239
+ const guarded = entry.profile === "guarded";
888
1240
  return {
889
1241
  entry,
890
1242
  auditRoot: entry.resolvedRoot,
891
- ledgerRoot: corp ? corpPersistenceRoot(manifest, name) : entry.resolvedRoot,
892
- corp
1243
+ ledgerRoot: guarded ? guardedPersistenceRoot(manifest, name) : entry.resolvedRoot,
1244
+ guarded
893
1245
  };
894
1246
  }
895
1247
  async function ensureFindingRecorded(manifest, name, id) {
@@ -898,398 +1250,11 @@ async function ensureFindingRecorded(manifest, name, id) {
898
1250
  if (await has()) return { ledgerRoot: target.ledgerRoot, recorded: true };
899
1251
  await runAudit(target.auditRoot, {
900
1252
  persistLedger: true,
901
- readOnlyRoot: target.corp,
902
- ledgerRoot: target.corp ? target.ledgerRoot : void 0,
1253
+ readOnlyRoot: target.guarded,
1254
+ ledgerRoot: target.guarded ? target.ledgerRoot : void 0,
903
1255
  stateBudgets: stateBudgetsFor(target.entry)
904
1256
  });
905
1257
  return { ledgerRoot: target.ledgerRoot, recorded: await has() };
906
1258
  }
907
1259
 
908
- // src/commands/fleet.ts
909
- var LAST_SWEEP_FILE = "last.fleet.json";
910
- async function loadManifest(cwd, manifestOpt) {
911
- if (manifestOpt) {
912
- return { manifest: await loadFleetManifest(path.resolve(cwd, manifestOpt)) };
913
- }
914
- const candidate = path.join(cwd, "registry.json");
915
- if (await pathExists(candidate)) {
916
- return {
917
- manifest: await loadFleetManifest(candidate),
918
- autoNote: `manifest auto-selected from the cwd: ${candidate} (no --manifest given)`
919
- };
920
- }
921
- throw new Error(
922
- "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)"
923
- );
924
- }
925
- async function readLastSweep(lastPath) {
926
- const raw = await readText(lastPath);
927
- if (raw === null) return { record: null };
928
- let parsed;
929
- try {
930
- parsed = JSON.parse(raw);
931
- } catch {
932
- return {
933
- record: null,
934
- note: `${LAST_SWEEP_FILE} exists but is not valid JSON \u2014 delta baseline reset (every finding reads as new this sweep).`
935
- };
936
- }
937
- const rec = parsed;
938
- 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)) {
939
- return {
940
- record: null,
941
- note: `${LAST_SWEEP_FILE} carries a foreign schema \u2014 delta baseline reset (every finding reads as new this sweep).`
942
- };
943
- }
944
- return { record: rec };
945
- }
946
- function failOnExit(failOn, findings) {
947
- if (failOn === void 0) return;
948
- if (meetsFailOn(findings, parseFailOnTier(failOn))) process.exitCode = 1;
949
- }
950
- async function sweep(opts) {
951
- if (opts.profile && opts.profile !== "personal" && opts.profile !== "corp") {
952
- throw new Error(`--profile must be personal or corp, got \`${opts.profile}\``);
953
- }
954
- if (opts.failOn !== void 0) parseFailOnTier(opts.failOn);
955
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
956
- const result = await sweepFleet(manifest, {
957
- only: opts.only,
958
- profile: opts.profile,
959
- kind: opts.truth ? "truth" : void 0,
960
- persistLedgers: opts.persistLedgers
961
- });
962
- const lastPath = path.join(manifest.dir, LAST_SWEEP_FILE);
963
- const { record: previous, note: baselineNote } = await readLastSweep(lastPath);
964
- const previousIds = (name) => new Set((previous?.projects[name] ?? []).map((e) => e.id));
965
- const previousWallIds = new Set((previous?.wall ?? []).map((e) => e.id));
966
- const allFindings = [...result.projects.flatMap((p) => p.findings), ...result.wall];
967
- failOnExit(opts.failOn, allFindings);
968
- const fullSweep = !opts.only && !opts.profile && !opts.truth;
969
- const delta = {};
970
- for (const p of result.projects) {
971
- const prev = previousIds(p.name);
972
- const current = new Set(p.findings.map((f) => f.id));
973
- delta[p.name] = {
974
- new: [...current].filter((id) => !prev.has(id)),
975
- resolved: [...prev].filter((id) => !current.has(id))
976
- };
977
- }
978
- const wallNew = result.wall.filter((f) => !previousWallIds.has(f.id)).map((f) => f.id);
979
- if (opts.json) {
980
- print(
981
- JSON.stringify(
982
- {
983
- ...result,
984
- delta: previous ? delta : null,
985
- ...baselineNote ? { deltaBaselineNote: baselineNote } : {}
986
- },
987
- null,
988
- 2
989
- )
990
- );
991
- } else {
992
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
993
- if (baselineNote) print(` ${theme.dim(`\u25E6 ${baselineNote}`)}`);
994
- renderSweep(result, previous, delta, wallNew);
995
- }
996
- if (fullSweep) {
997
- const record = {
998
- schema: FLEET_JSON_SCHEMA,
999
- sweptAt: result.sweptAt,
1000
- projects: Object.fromEntries(
1001
- result.projects.map((p) => [p.name, p.findings.map((f) => ({ id: f.id, tier: f.tier }))])
1002
- ),
1003
- wall: result.wall.map((f) => ({ id: f.id, tier: f.tier }))
1004
- };
1005
- const tmpPath = `${lastPath}.tmp`;
1006
- await promises.writeFile(tmpPath, JSON.stringify(record, null, 2) + "\n", "utf8");
1007
- await promises.rename(tmpPath, lastPath);
1008
- } else if (!opts.json) {
1009
- print();
1010
- print(` ${theme.dim("partial sweep \u2014 the delta baseline was not updated")}`);
1011
- }
1012
- }
1013
- function renderSweep(result, previous, delta, wallNew) {
1014
- section(
1015
- `Fleet sweep ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 ${result.projects.length} project(s) \xB7 schema ${result.schema}`)}`
1016
- );
1017
- const rows = result.projects.map((p) => {
1018
- const d = delta[p.name] ?? { new: [], resolved: [] };
1019
- 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";
1020
- return {
1021
- name: p.name,
1022
- age: p.stateAgeDays === null ? "\u2014" : `${p.stateAgeDays}/${p.staleAfterDays}d`,
1023
- counts: p.counts,
1024
- delta: deltaStr,
1025
- note: p.unresolved
1026
- };
1027
- });
1028
- renderFleetRows(rows);
1029
- renderFleetNotes(
1030
- result.problems,
1031
- result.projects.flatMap((p) => p.disclosures.map((d) => `${p.name}: ${d}`))
1032
- );
1033
- const detail = [
1034
- ...result.projects.flatMap(
1035
- (p) => p.findings.filter((f) => f.tier === "risk" || (delta[p.name]?.new ?? []).includes(f.id))
1036
- ),
1037
- ...result.wall.filter((f) => f.tier === "risk" || wallNew.includes(f.id))
1038
- ];
1039
- if (detail.length) {
1040
- section(`New or risk findings ${theme.dim(`(${detail.length})`)}`);
1041
- renderFindings(detail);
1042
- }
1043
- if (result.recurringClasses.length) {
1044
- section(`Recurring classes ${theme.dim("(open in \u22652 projects \u2014 class-fix candidates)")}`);
1045
- for (const rc of result.recurringClasses) {
1046
- print(
1047
- ` ${TIER_BADGE[rc.tier]} ${rc.classId} ${theme.dim(`\u2014 ${rc.projects.length}\xD7: ${rc.projects.join(", ")}`)}`
1048
- );
1049
- }
1050
- }
1051
- section("Fleet wall");
1052
- if (!result.wall.length) print(` ${glyph.ok} ${theme.dim("clean")}`);
1053
- else
1054
- print(
1055
- ` ${theme.warn(`${result.wall.length} finding(s)`)} ${theme.dim("(detailed above if new or risk)")}`
1056
- );
1057
- renderFleetNotes([], result.wallDisclosures);
1058
- if (result.outOfScope.length) {
1059
- print(
1060
- ` ${theme.dim(`out of scope (not audited, named above): ${result.outOfScope.join(", ")}`)}`
1061
- );
1062
- }
1063
- }
1064
- async function check(opts) {
1065
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1066
- const { findings, disclosures } = await checkManifest(manifest);
1067
- if (findings.length) process.exitCode = 1;
1068
- if (opts.json) {
1069
- print(
1070
- JSON.stringify(
1071
- { schema: FLEET_JSON_SCHEMA, manifest: manifest.manifestPath, findings, disclosures },
1072
- null,
1073
- 2
1074
- )
1075
- );
1076
- return;
1077
- }
1078
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1079
- section(
1080
- `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`)}`
1081
- );
1082
- renderFindings(findings);
1083
- renderFleetNotes([], disclosures);
1084
- }
1085
- async function recordCorpMapping(manifest, name, absTarget) {
1086
- const home = os.homedir();
1087
- const value = absTarget === home || absTarget.startsWith(home + path.sep) ? `~${absTarget.slice(home.length)}` : absTarget;
1088
- const insideRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
1089
- if (insideRepo) {
1090
- const ignored = await git(manifest.dir, ["check-ignore", "-q", manifest.localPath]) !== null;
1091
- if (!ignored) {
1092
- throw new Error(
1093
- `${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.`
1094
- );
1095
- }
1096
- }
1097
- const existing = await readText(manifest.localPath);
1098
- let doc = {};
1099
- if (existing) {
1100
- try {
1101
- doc = JSON.parse(existing);
1102
- } catch {
1103
- throw new Error(
1104
- `${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.`
1105
- );
1106
- }
1107
- }
1108
- const dirs = doc.dirs ?? {};
1109
- dirs[name] = value;
1110
- doc.dirs = dirs;
1111
- await promises.writeFile(manifest.localPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
1112
- return value;
1113
- }
1114
- async function add(opts) {
1115
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1116
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1117
- if (manifest.shape !== "registry") {
1118
- throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
1119
- }
1120
- const raw = await readText(manifest.manifestPath);
1121
- if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
1122
- const absTarget = path.resolve(opts.cwd, opts.target);
1123
- if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
1124
- const name = opts.name ?? path.basename(absTarget);
1125
- if (manifest.entries.some((e) => e.name === name)) {
1126
- throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
1127
- }
1128
- const facts = await scanProject(absTarget);
1129
- const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
1130
- section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
1131
- let profile = opts.profile;
1132
- if (!profile && !opts.yes) {
1133
- const picked = await select({
1134
- message: "Which side of the wall does this belong to?",
1135
- initialValue: "personal",
1136
- options: [
1137
- { value: "personal", label: "personal \u2014 your own work" },
1138
- { value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
1139
- ]
1140
- });
1141
- if (isCancel(picked)) {
1142
- cancel("No entry written.");
1143
- return;
1144
- }
1145
- profile = picked;
1146
- }
1147
- profile = profile ?? "personal";
1148
- if (profile !== "personal" && profile !== "corp") {
1149
- throw new Error(`--profile must be personal or corp, got \`${profile}\``);
1150
- }
1151
- const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
1152
- let kind = opts.kind;
1153
- if (!kind && !opts.yes) {
1154
- const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
1155
- const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
1156
- const picked = await select({
1157
- message: "Kind?",
1158
- initialValue: derivedKind,
1159
- options: [
1160
- ...choices.map((k) => ({
1161
- value: k,
1162
- label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
1163
- })),
1164
- { value: "", label: "something else\u2026" }
1165
- ]
1166
- });
1167
- if (isCancel(picked)) {
1168
- cancel("No entry written.");
1169
- return;
1170
- }
1171
- kind = picked;
1172
- if (!kind) {
1173
- const typed = await text({
1174
- message: "Kind (free text):",
1175
- validate: (v) => v.trim() ? void 0 : "a kind is required"
1176
- });
1177
- if (isCancel(typed)) {
1178
- cancel("No entry written.");
1179
- return;
1180
- }
1181
- kind = typed.trim();
1182
- }
1183
- }
1184
- kind = kind ?? derivedKind;
1185
- let trust;
1186
- if (profile === "personal") {
1187
- trust = opts.trust;
1188
- if (!trust && !opts.yes) {
1189
- const picked = await select({
1190
- message: "How exposed is this repo's history? (gates content screening)",
1191
- options: [
1192
- { value: "private", label: "private \u2014 not destined to be published" },
1193
- { value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
1194
- { value: "public-repo", label: "public-repo \u2014 already public" }
1195
- ]
1196
- });
1197
- if (isCancel(picked)) {
1198
- cancel("No entry written.");
1199
- return;
1200
- }
1201
- trust = picked;
1202
- }
1203
- if (!trust) {
1204
- throw new Error(
1205
- `\`${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)`
1206
- );
1207
- }
1208
- if (!isFleetTrust(trust)) {
1209
- throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
1210
- }
1211
- } else if (opts.trust) {
1212
- throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
1213
- }
1214
- const corpHost = remote ? manifest.corpHosts.find((h) => remote.includes(h)) : void 0;
1215
- if (corpHost && profile !== "corp") {
1216
- throw new Error(
1217
- `\`${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.`
1218
- );
1219
- }
1220
- const entry = profile === "corp" ? { name, kind, profile, private: true } : {
1221
- name,
1222
- kind,
1223
- profile,
1224
- path: path.relative(manifest.root ?? manifest.dir, absTarget),
1225
- trust,
1226
- contract: {},
1227
- links: {}
1228
- // The remote is deliberately NOT recorded. Nothing reads it — it was persisted only
1229
- // because it happened to be derivable — and it is the field that turns a mis-profiled
1230
- // entry into a disclosure: a raw URL carries the host AND the internal group path,
1231
- // while `path` carries a bare directory name. Removing a field no consumer reads
1232
- // retires that whole class without any host-matching guess, and keeps working on a
1233
- // machine that has no local manifest for the corp-host guard above to read.
1234
- // It stays derivable at any time from the checkout itself, which is where it came from.
1235
- };
1236
- print(` ${glyph.bullet} ${theme.dim("entry")}`);
1237
- print(
1238
- JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
1239
- );
1240
- if (!opts.yes) {
1241
- const ok = await confirm({
1242
- message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
1243
- });
1244
- if (isCancel(ok) || !ok) {
1245
- cancel("No entry written.");
1246
- return;
1247
- }
1248
- }
1249
- let mappedTo;
1250
- if (profile === "corp") {
1251
- mappedTo = await recordCorpMapping(manifest, name, absTarget);
1252
- }
1253
- const doc = JSON.parse(raw);
1254
- if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
1255
- doc.projects.push(entry);
1256
- await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
1257
- print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
1258
- if (mappedTo) {
1259
- print(
1260
- ` ${glyph.ok} ${theme.dim("mapped")} ${theme.info(`${name} \u2192 ${mappedTo}`)} ${theme.dim(`in ${path.basename(manifest.localPath)} (gitignored)`)}`
1261
- );
1262
- }
1263
- print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
1264
- }
1265
- async function resolveFleetFinding(opts, status) {
1266
- if (status === "dismissed" && !opts.reason?.trim()) {
1267
- throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
1268
- }
1269
- const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
1270
- if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
1271
- const target = await fleetLedgerTarget(manifest, opts.name);
1272
- const hadLedger = await pathExists(path.join(target.ledgerRoot, ".etymd"));
1273
- const { ledgerRoot, recorded } = await ensureFindingRecorded(manifest, opts.name, opts.id);
1274
- if (!recorded) {
1275
- throw new Error(
1276
- `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)`
1277
- );
1278
- }
1279
- if (!hadLedger) {
1280
- print(
1281
- ` ${theme.dim(`first ledger for \`${opts.name}\`: created ${path.join(ledgerRoot, ".etymd")} (a persisting audit ran to record the finding)`)}`
1282
- );
1283
- }
1284
- const args = { cwd: ledgerRoot, id: opts.id, reason: opts.reason };
1285
- await (status === "dismissed" ? dismiss(args) : accept(args));
1286
- print(` ${theme.dim(`ledger: ${path.join(ledgerRoot, ".etymd", "ledger.json")}`)}`);
1287
- }
1288
- async function dismiss2(opts) {
1289
- await resolveFleetFinding(opts, "dismissed");
1290
- }
1291
- async function accept2(opts) {
1292
- await resolveFleetFinding(opts, "accepted");
1293
- }
1294
-
1295
- export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };
1260
+ export { BOARD_JSON_SCHEMA, FLEET_JSON_SCHEMA, FLEET_TRUST_VALUES, MILESTONES_FILE, checkManifest, ensureFindingRecorded, fleetLedgerTarget, isFleetTrust, loadFleetManifest, parseInitiatives, parseMilestones, renderBoard, sweepFleet };