etymd 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { promises } from 'node:fs';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
  import YAML from 'yaml';
6
+ import os from 'node:os';
6
7
 
7
8
  // src/core/scan.ts
8
9
 
@@ -12,7 +13,7 @@ var PACK_VERSION = "2";
12
13
  // package.json
13
14
  var package_default = {
14
15
  name: "etymd",
15
- version: "0.1.0"};
16
+ version: "0.2.0"};
16
17
 
17
18
  // src/version.ts
18
19
  var VERSION = package_default.version;
@@ -442,6 +443,19 @@ var ARTIFACT_SPECS = [
442
443
  path: "PROJECT_CONTEXT.md",
443
444
  kind: "state"
444
445
  },
446
+ {
447
+ id: "decisions",
448
+ label: "DECISIONS.md (decision record)",
449
+ path: "DECISIONS.md",
450
+ kind: "decisions"
451
+ },
452
+ { id: "adr-dir", label: "ADR directory (docs/adr)", path: "docs/adr", kind: "decisions" },
453
+ {
454
+ id: "decisions-dir",
455
+ label: "ADR directory (docs/decisions)",
456
+ path: "docs/decisions",
457
+ kind: "decisions"
458
+ },
445
459
  { id: "claude", label: "CLAUDE.md (Claude Code pointer)", path: "CLAUDE.md", kind: "adapter" },
446
460
  {
447
461
  id: "copilot",
@@ -487,12 +501,25 @@ var ARTIFACT_SPECS = [
487
501
  }
488
502
  ];
489
503
  async function detectArtifacts(root) {
490
- return Promise.all(
504
+ const artifacts = await Promise.all(
491
505
  ARTIFACT_SPECS.map(async (spec) => ({
492
506
  ...spec,
493
507
  exists: await pathExists(path.join(root, spec.path))
494
508
  }))
495
509
  );
510
+ let adrFiles = false;
511
+ try {
512
+ adrFiles = (await promises.readdir(path.join(root, "docs"))).some((f) => /^\d{4}-.+\.md$/.test(f));
513
+ } catch {
514
+ }
515
+ artifacts.push({
516
+ id: "adr-files",
517
+ label: "ADR files (docs/NNNN-*.md)",
518
+ path: "docs",
519
+ kind: "decisions",
520
+ exists: adrFiles
521
+ });
522
+ return artifacts;
496
523
  }
497
524
  async function walkTree(root) {
498
525
  const state = { budget: 2e4, truncated: false };
@@ -557,7 +584,50 @@ function inferTicketKey(subjects) {
557
584
  }
558
585
  return bestCount >= 3 ? best : void 0;
559
586
  }
560
- async function scanProject(root) {
587
+ async function collectFreshness(abs, isRepo, artifacts, upstreamRemote) {
588
+ const dated = artifacts.filter((a) => a.exists && (a.kind === "state" || a.kind === "decisions"));
589
+ const allUnverifiable = (reason) => ({
590
+ artifacts: [],
591
+ unverifiable: dated.map((a) => ({ path: a.path, reason }))
592
+ });
593
+ if (!isRepo) return allUnverifiable("not a git repository \u2014 no committer dates to read");
594
+ if (await git(abs, ["rev-parse", "--is-shallow-repository"]) === "true") {
595
+ return allUnverifiable("shallow clone \u2014 history is incomplete, committer dates would lie");
596
+ }
597
+ const repoLastCommit = upstreamRemote ? await git(abs, ["log", "-1", "--format=%cI", "HEAD", "--not", `--remotes=${upstreamRemote}`]) : await git(abs, ["log", "-1", "--format=%cI"]);
598
+ if (!repoLastCommit) {
599
+ if (upstreamRemote) {
600
+ return allUnverifiable(
601
+ `no fork-authored commits \u2014 every commit is reachable from \`${upstreamRemote}\`, the fork has not moved`
602
+ );
603
+ }
604
+ return allUnverifiable("repository has no commits yet");
605
+ }
606
+ const dirtyRaw = await git(abs, ["diff", "--name-only", "HEAD"]);
607
+ const dirtyPaths = dirtyRaw ? dirtyRaw.split("\n").filter(Boolean) : [];
608
+ const isDirty = (p) => dirtyPaths.some((d) => d === p || d.startsWith(`${p}/`));
609
+ const facts = { repoLastCommit, artifacts: [], unverifiable: [] };
610
+ await Promise.all(
611
+ dated.map(async (a) => {
612
+ const lastCommit = await git(abs, ["log", "-1", "--format=%cI", "--", a.path]);
613
+ if (!lastCommit) {
614
+ facts.unverifiable.push({ path: a.path, reason: "untracked \u2014 never committed" });
615
+ return;
616
+ }
617
+ facts.artifacts.push({
618
+ artifactId: a.id,
619
+ path: a.path,
620
+ lastCommit,
621
+ commitsSince: Date.parse(repoLastCommit) > Date.parse(lastCommit),
622
+ ...isDirty(a.path) ? { dirty: true } : {}
623
+ });
624
+ })
625
+ );
626
+ facts.artifacts.sort((x, y) => x.path.localeCompare(y.path));
627
+ facts.unverifiable.sort((x, y) => x.path.localeCompare(y.path));
628
+ return facts;
629
+ }
630
+ async function scanProject(root, opts = {}) {
561
631
  const abs = path.resolve(root);
562
632
  const rootPkg = await readJson(path.join(abs, "package.json"));
563
633
  const [packageManager, workspace, ci, artifacts, tree, isRepo] = await Promise.all([
@@ -577,6 +647,7 @@ async function scanProject(root) {
577
647
  ]) : [null, null, null, null, null];
578
648
  const hooksPath = hooksPathRaw ?? void 0;
579
649
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
650
+ const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
580
651
  const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
581
652
  const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
582
653
  return {
@@ -603,6 +674,7 @@ async function scanProject(root) {
603
674
  ci,
604
675
  hooks,
605
676
  artifacts,
677
+ freshness,
606
678
  tree
607
679
  };
608
680
  }
@@ -651,7 +723,8 @@ function deriveProfile(facts) {
651
723
  var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
652
724
  var DEFAULT_CONFIG = {
653
725
  instructions: { include: [], exclude: [] },
654
- context: { perFileWords: 4e3, totalWords: 8e3 }
726
+ context: { perFileWords: 4e3, totalWords: 8e3 },
727
+ state: { staleAfterDays: 30, maxChars: 9500 }
655
728
  };
656
729
  function configPath(root) {
657
730
  return path.join(root, CONFIG_FILE);
@@ -695,6 +768,7 @@ async function readConfig(root) {
695
768
  const obj = parsed;
696
769
  const instructions = obj.instructions ?? {};
697
770
  const context = obj.context ?? {};
771
+ const state = obj.state ?? {};
698
772
  return {
699
773
  present: true,
700
774
  problems,
@@ -706,6 +780,10 @@ async function readConfig(root) {
706
780
  context: {
707
781
  perFileWords: readBudget(context.perFileWords, "context.perFileWords", problems) ?? DEFAULT_CONFIG.context.perFileWords,
708
782
  totalWords: readBudget(context.totalWords, "context.totalWords", problems) ?? DEFAULT_CONFIG.context.totalWords
783
+ },
784
+ state: {
785
+ staleAfterDays: readBudget(state.staleAfterDays, "state.staleAfterDays", problems) ?? DEFAULT_CONFIG.state.staleAfterDays,
786
+ maxChars: readBudget(state.maxChars, "state.maxChars", problems) ?? DEFAULT_CONFIG.state.maxChars
709
787
  }
710
788
  }
711
789
  };
@@ -714,6 +792,7 @@ async function readConfig(root) {
714
792
  // src/core/context.ts
715
793
  var ALWAYS_LOADED = [
716
794
  { path: "AGENTS.md", role: "operating contract" },
795
+ { path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
717
796
  { path: "CLAUDE.md", role: "Claude Code pointer" },
718
797
  { path: "GEMINI.md", role: "Gemini pointer" },
719
798
  { path: ".github/copilot-instructions.md", role: "Copilot instructions" },
@@ -1652,7 +1731,13 @@ function packageManagerUsage(text) {
1652
1731
  }
1653
1732
  return counts;
1654
1733
  }
1655
- var KNOWN_DOC_REFS = ["AGENTS.md", "CLAUDE.md", "PROJECT_CONTEXT.md", "GEMINI.md"];
1734
+ var KNOWN_DOC_REFS = [
1735
+ "AGENTS.md",
1736
+ "CLAUDE.md",
1737
+ "PROJECT_CONTEXT.md",
1738
+ "DECISIONS.md",
1739
+ "GEMINI.md"
1740
+ ];
1656
1741
  function extractDocRefs(text) {
1657
1742
  return KNOWN_DOC_REFS.filter((name) => text.includes(name));
1658
1743
  }
@@ -1950,6 +2035,206 @@ var instructionTruthLens = {
1950
2035
  };
1951
2036
  }
1952
2037
  };
2038
+ var LENS_ID4 = "state-freshness";
2039
+ var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
2040
+ var MS_PER_DAY = 864e5;
2041
+ function parseDecisionEntries(text) {
2042
+ const headings = [...text.matchAll(/^## .*$/gm)];
2043
+ const entries = [];
2044
+ for (let i = 0; i < headings.length; i++) {
2045
+ const h = headings[i];
2046
+ const m = /^## (D-(\d+))\b/.exec(h[0]);
2047
+ if (!m) continue;
2048
+ const start = (h.index ?? 0) + h[0].length;
2049
+ const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
2050
+ entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
2051
+ }
2052
+ return entries;
2053
+ }
2054
+ function checkIdSequence(file, entries) {
2055
+ const findings = [];
2056
+ const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
2057
+ const seen = /* @__PURE__ */ new Map();
2058
+ const duplicated = /* @__PURE__ */ new Set();
2059
+ let prev;
2060
+ for (const entry of entries) {
2061
+ if (seen.has(entry.num)) {
2062
+ if (!duplicated.has(entry.num)) {
2063
+ duplicated.add(entry.num);
2064
+ findings.push({
2065
+ id: `${LENS_ID4}/duplicate-id:${file}:${entry.id}`,
2066
+ lens: LENS_ID4,
2067
+ tier: "gap",
2068
+ claim: `${file} carries more than one ${entry.id} entry`,
2069
+ evidence: [`${file}: ${entry.id} appears twice`],
2070
+ why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
2071
+ action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
2072
+ effort: "S",
2073
+ confidence: "high"
2074
+ });
2075
+ }
2076
+ } else {
2077
+ seen.set(entry.num, entry);
2078
+ if (prev && entry.num < prev.num) {
2079
+ findings.push({
2080
+ id: `${LENS_ID4}/id-order:${file}:${entry.id}`,
2081
+ lens: LENS_ID4,
2082
+ tier: "gap",
2083
+ claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
2084
+ evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
2085
+ why: "An append-only record reads in id order; out-of-order ids make the newest decision hard to find and the next id hard to pick.",
2086
+ action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
2087
+ effort: "S",
2088
+ confidence: "high"
2089
+ });
2090
+ }
2091
+ prev = entry;
2092
+ }
2093
+ }
2094
+ return findings;
2095
+ }
2096
+ function checkFormatFields(file, entries, today) {
2097
+ const findings = [];
2098
+ for (const entry of entries) {
2099
+ if (!/Scope[\s*]*:/.test(entry.block)) {
2100
+ findings.push({
2101
+ id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
2102
+ lens: LENS_ID4,
2103
+ tier: "gap",
2104
+ claim: `${file} ${entry.id} has no Scope: field`,
2105
+ evidence: [`${file}: ${entry.id}`],
2106
+ why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
2107
+ action: "Add a Scope: line naming what the decision binds.",
2108
+ effort: "S",
2109
+ confidence: "high"
2110
+ });
2111
+ }
2112
+ const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
2113
+ if (revisit && revisit[1] < today) {
2114
+ findings.push({
2115
+ id: `${LENS_ID4}/revisit-due:${file}:${entry.id}`,
2116
+ lens: LENS_ID4,
2117
+ tier: "gap",
2118
+ claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
2119
+ evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
2120
+ why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
2121
+ action: "Re-evaluate the decision: supersede it or move the Revisit date.",
2122
+ effort: "S",
2123
+ confidence: "high"
2124
+ });
2125
+ }
2126
+ }
2127
+ return findings;
2128
+ }
2129
+ var stateFreshnessLens = {
2130
+ id: LENS_ID4,
2131
+ version: "1",
2132
+ title: "State freshness",
2133
+ kind: "truth",
2134
+ async run(ctx) {
2135
+ const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
2136
+ const findings = [];
2137
+ const disclosures = [...ctx.config?.problems ?? []];
2138
+ const outOfScope = [];
2139
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2140
+ const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
2141
+ const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
2142
+ if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
2143
+ disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
2144
+ }
2145
+ const freshness = ctx.facts.freshness;
2146
+ if (!freshness) {
2147
+ disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
2148
+ } else {
2149
+ for (const u of freshness.unverifiable) {
2150
+ disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
2151
+ }
2152
+ for (const a of stateArtifacts) {
2153
+ const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
2154
+ if (!fact || !freshness.repoLastCommit) continue;
2155
+ if (fact.dirty) {
2156
+ disclosures.push(
2157
+ `${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
2158
+ );
2159
+ continue;
2160
+ }
2161
+ if (!fact.commitsSince) continue;
2162
+ const gapDays = Math.floor(
2163
+ (Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
2164
+ );
2165
+ if (gapDays <= budgets.staleAfterDays) continue;
2166
+ const escalated = gapDays > budgets.staleAfterDays * 3;
2167
+ findings.push({
2168
+ id: `${LENS_ID4}/stale-state:${a.path}`,
2169
+ lens: LENS_ID4,
2170
+ tier: escalated ? "risk" : "gap",
2171
+ claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
2172
+ evidence: [
2173
+ `${a.path} last commit: ${fact.lastCommit}`,
2174
+ `repo last commit: ${freshness.repoLastCommit}`
2175
+ ],
2176
+ why: escalated ? `Over three times the ${budgets.staleAfterDays}-day threshold while commits kept landing \u2014 every session starts from a picture of the project that is no longer true.` : `A state doc more than ${budgets.staleAfterDays} days behind continued commit traffic misleads every session that loads it.`,
2177
+ action: "Refresh the state doc (or record why it is still current).",
2178
+ effort: "S",
2179
+ confidence: "high"
2180
+ });
2181
+ }
2182
+ }
2183
+ for (const a of stateArtifacts) {
2184
+ const text = await readText(path.join(ctx.root, a.path));
2185
+ if (text === null) {
2186
+ disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
2187
+ outOfScope.push(a.path);
2188
+ continue;
2189
+ }
2190
+ if (text.length > budgets.maxChars) {
2191
+ findings.push({
2192
+ id: `${LENS_ID4}/state-over-budget:${a.path}`,
2193
+ lens: LENS_ID4,
2194
+ tier: "gap",
2195
+ claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
2196
+ evidence: [`${a.path}: ${text.length} chars`],
2197
+ why: "Session-injection hooks truncate state around 10,000 chars \u2014 an over-budget state doc gets cut mid-sentence, and every session pays its full weight before the task begins.",
2198
+ action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
2199
+ effort: "M",
2200
+ confidence: "high"
2201
+ });
2202
+ }
2203
+ }
2204
+ for (const a of decisionArtifacts) {
2205
+ const text = await readText(path.join(ctx.root, a.path));
2206
+ if (text === null) {
2207
+ disclosures.push(
2208
+ `${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
2209
+ );
2210
+ continue;
2211
+ }
2212
+ const entries = parseDecisionEntries(text);
2213
+ findings.push(...checkIdSequence(a.path, entries));
2214
+ if (!text.includes(DECISIONS_FORMAT_MARKER)) {
2215
+ disclosures.push(
2216
+ `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
2217
+ );
2218
+ outOfScope.push(a.path);
2219
+ continue;
2220
+ }
2221
+ findings.push(...checkFormatFields(a.path, entries, today));
2222
+ }
2223
+ disclosures.push(
2224
+ `Thresholds: staleAfterDays ${budgets.staleAfterDays} (3x escalates to risk), state budget ${budgets.maxChars} chars (${budgets.staleAfterDays === DEFAULT_CONFIG.state.staleAfterDays && budgets.maxChars === DEFAULT_CONFIG.state.maxChars ? `defaults \u2014 override under \`state\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Decisions artifacts are exempt from age \u2014 old decisions are history, not defects.`
2225
+ );
2226
+ return {
2227
+ lens: LENS_ID4,
2228
+ version: "1",
2229
+ title: "State freshness",
2230
+ kind: "truth",
2231
+ status: "ran",
2232
+ disclosures,
2233
+ findings,
2234
+ ...outOfScope.length ? { outOfScope } : {}
2235
+ };
2236
+ }
2237
+ };
1953
2238
 
1954
2239
  // src/engine/finding.ts
1955
2240
  var TIER_ORDER = { risk: 0, gap: 1, polish: 2 };
@@ -2043,14 +2328,26 @@ function visibleFindings(findings, ledger) {
2043
2328
  }
2044
2329
 
2045
2330
  // src/engine/run.ts
2046
- var LENSES = [instructionTruthLens, gateIntegrityLens, contextEconomyLens];
2331
+ var LENSES = [
2332
+ instructionTruthLens,
2333
+ stateFreshnessLens,
2334
+ gateIntegrityLens,
2335
+ contextEconomyLens
2336
+ ];
2047
2337
  async function runAudit(root, opts = {}) {
2048
- const facts = await scanProject(root);
2049
- if ((opts.persistLedger ?? true) || await pathExists(path.join(root, ETYMD_DIR))) {
2338
+ const facts = await scanProject(root, { upstreamRemote: opts.upstreamRemote });
2339
+ const ledgerRoot = opts.ledgerRoot ?? root;
2340
+ if (!opts.readOnlyRoot && ((opts.persistLedger ?? true) || await pathExists(path.join(root, ETYMD_DIR)))) {
2050
2341
  await writeCachedFacts(root, facts);
2051
2342
  }
2052
2343
  const baseline = await readBaseline(root);
2053
- const config = await readConfig(root);
2344
+ let config = await readConfig(root);
2345
+ if (opts.stateBudgets) {
2346
+ config = {
2347
+ ...config,
2348
+ config: { ...config.config, state: { ...config.config.state, ...opts.stateBudgets } }
2349
+ };
2350
+ }
2054
2351
  const profile = baseline?.profile ?? deriveProfile(facts);
2055
2352
  const selected = LENSES.filter(
2056
2353
  (lens) => (!opts.kind || lens.kind === opts.kind) && (!opts.lensIds || opts.lensIds.includes(lens.id))
@@ -2073,12 +2370,13 @@ async function runAudit(root, opts = {}) {
2073
2370
  }
2074
2371
  }
2075
2372
  const all = reports.flatMap((r) => r.findings);
2076
- const previous = await readLedger(root);
2373
+ const previous = await readLedger(ledgerRoot);
2077
2374
  const outOfScope = reports.flatMap((r) => r.outOfScope ?? []);
2078
2375
  const { ledger, diff } = reconcileLedger(previous, all, void 0, outOfScope);
2079
2376
  const fullRun = selected.length === LENSES.length;
2080
- if ((opts.persistLedger ?? true) && fullRun) {
2081
- await writeLedger(root, ledger);
2377
+ const ledgerWritable = !opts.readOnlyRoot || ledgerRoot !== root;
2378
+ if ((opts.persistLedger ?? true) && fullRun && ledgerWritable) {
2379
+ await writeLedger(ledgerRoot, ledger);
2082
2380
  }
2083
2381
  return {
2084
2382
  facts,
@@ -2091,5 +2389,732 @@ async function runAudit(root, opts = {}) {
2091
2389
  diff
2092
2390
  };
2093
2391
  }
2392
+ function expandTilde(p) {
2393
+ if (p === "~") return os.homedir();
2394
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
2395
+ return p;
2396
+ }
2397
+ var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"corp","dirs":{"<name>":"~/path/to/worktree"}}';
2398
+ var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
2399
+ function rejectUnsafeName(name, where, manifest, manifestFile) {
2400
+ if (SAFE_NAME.test(name)) return false;
2401
+ manifest.problems.push({
2402
+ kind: "bad-shape",
2403
+ file: manifestFile,
2404
+ 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)`
2405
+ });
2406
+ return true;
2407
+ }
2408
+ function asRecord(value) {
2409
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2410
+ }
2411
+ function asString(value) {
2412
+ return typeof value === "string" && value.trim() ? value : void 0;
2413
+ }
2414
+ function asStringMap(value) {
2415
+ const rec = asRecord(value);
2416
+ if (!rec) return {};
2417
+ const out = {};
2418
+ for (const [k, v] of Object.entries(rec)) if (typeof v === "string") out[k] = v;
2419
+ return out;
2420
+ }
2421
+ async function readLocal(localPath, problems) {
2422
+ const raw = await readText(localPath);
2423
+ if (raw === null) return { present: false, data: {} };
2424
+ try {
2425
+ const parsed = asRecord(JSON.parse(raw));
2426
+ if (!parsed) {
2427
+ problems.push({
2428
+ kind: "parse-error",
2429
+ file: path.basename(localPath),
2430
+ detail: "must contain a JSON object \u2014 local mappings unusable"
2431
+ });
2432
+ return { present: true, data: {} };
2433
+ }
2434
+ return { present: true, data: parsed };
2435
+ } catch (err) {
2436
+ problems.push({
2437
+ kind: "parse-error",
2438
+ file: path.basename(localPath),
2439
+ detail: `not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 local mappings unusable`
2440
+ });
2441
+ return { present: true, data: {} };
2442
+ }
2443
+ }
2444
+ function loadRegistryEntries(projects, manifest, manifestFile) {
2445
+ const corpNames = [];
2446
+ for (const [i, rawEntry] of projects.entries()) {
2447
+ const rec = asRecord(rawEntry);
2448
+ const name = rec ? asString(rec.name) : void 0;
2449
+ if (!rec || !name) {
2450
+ manifest.problems.push({
2451
+ kind: "bad-shape",
2452
+ file: manifestFile,
2453
+ detail: `projects[${i}] has no usable name \u2014 the entry cannot be resolved or audited`
2454
+ });
2455
+ continue;
2456
+ }
2457
+ if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
2458
+ const profile = rec.profile === "corp" ? "corp" : "personal";
2459
+ if (rec.profile !== void 0 && rec.profile !== "corp" && rec.profile !== "personal") {
2460
+ manifest.problems.push({
2461
+ kind: "bad-shape",
2462
+ file: manifestFile,
2463
+ detail: `\`${name}\` declares unknown profile \`${String(rec.profile)}\` \u2014 read as personal`
2464
+ });
2465
+ }
2466
+ const contract = {};
2467
+ const contractRec = asRecord(rec.contract);
2468
+ if (contractRec) {
2469
+ for (const [k, v] of Object.entries(contractRec)) {
2470
+ if (typeof v === "string") contract[k] = v;
2471
+ else {
2472
+ manifest.problems.push({
2473
+ kind: "bad-shape",
2474
+ file: manifestFile,
2475
+ detail: `\`${name}\`.contract.${k} is not a string \u2014 ignored`
2476
+ });
2477
+ }
2478
+ }
2479
+ }
2480
+ const entry = {
2481
+ name,
2482
+ kind: asString(rec.kind),
2483
+ profile,
2484
+ private: rec.private === true,
2485
+ path: asString(rec.path),
2486
+ upstream: asString(rec.upstream),
2487
+ trust: asString(rec.trust),
2488
+ staleAfterDays: typeof rec.staleAfterDays === "number" ? rec.staleAfterDays : void 0,
2489
+ stateBudget: typeof rec.stateBudget === "number" ? rec.stateBudget : void 0,
2490
+ contract,
2491
+ links: asStringMap(rec.links)
2492
+ };
2493
+ if (entry.profile === "corp" || entry.private) {
2494
+ corpNames.push(name);
2495
+ if (!manifest.localPresent) {
2496
+ entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
2497
+ } else if (manifest.machineProfile === "personal") {
2498
+ entry.unresolved = 'corp entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
2499
+ } else {
2500
+ const dir = manifest.localDirs[name];
2501
+ if (!dir) {
2502
+ entry.unresolved = `no \`dirs\` mapping for \`${name}\` in ${path.basename(manifest.localPath)}`;
2503
+ } else {
2504
+ entry.resolvedRoot = path.resolve(manifest.dir, expandTilde(dir));
2505
+ }
2506
+ }
2507
+ } else if (entry.path) {
2508
+ entry.resolvedRoot = path.resolve(manifest.root ?? manifest.dir, expandTilde(entry.path));
2509
+ } else {
2510
+ entry.unresolved = "personal entry declares no `path` \u2014 nothing to resolve";
2511
+ }
2512
+ manifest.entries.push(entry);
2513
+ }
2514
+ if (corpNames.length && !manifest.localPresent) {
2515
+ manifest.problems.push({
2516
+ kind: "local-missing",
2517
+ file: path.basename(manifest.localPath),
2518
+ detail: `${corpNames.length} corp/private entr${corpNames.length === 1 ? "y" : "ies"} (${corpNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
2519
+ });
2520
+ }
2521
+ }
2522
+ function loadCorpusEntries(sources, manifest, manifestFile) {
2523
+ for (const [i, rawEntry] of sources.entries()) {
2524
+ const rec = asRecord(rawEntry);
2525
+ const name = rec ? asString(rec.name) : void 0;
2526
+ if (!rec || !name) {
2527
+ manifest.problems.push({
2528
+ kind: "bad-shape",
2529
+ file: manifestFile,
2530
+ detail: `sources[${i}] has no usable name \u2014 the entry cannot be resolved`
2531
+ });
2532
+ continue;
2533
+ }
2534
+ if (rejectUnsafeName(name, `sources[${i}]`, manifest, manifestFile)) continue;
2535
+ const entry = {
2536
+ name,
2537
+ kind: asString(rec.shape),
2538
+ profile: "personal",
2539
+ private: rec.private === true,
2540
+ path: asString(rec.path),
2541
+ contract: {},
2542
+ links: {}
2543
+ };
2544
+ const localDir = manifest.localDirs[name];
2545
+ if (localDir) {
2546
+ entry.resolvedRoot = path.resolve(manifest.dir, "..", expandTilde(localDir));
2547
+ } else if (entry.path) {
2548
+ entry.resolvedRoot = path.resolve(manifest.dir, expandTilde(entry.path));
2549
+ } else {
2550
+ entry.resolvedRoot = path.resolve(manifest.dir, "..", name);
2551
+ }
2552
+ manifest.entries.push(entry);
2553
+ }
2554
+ }
2555
+ async function loadFleetManifest(manifestPath) {
2556
+ const abs = path.resolve(manifestPath);
2557
+ const dir = path.dirname(abs);
2558
+ const manifestFile = path.basename(abs);
2559
+ const raw = await readText(abs);
2560
+ if (raw === null) {
2561
+ throw new Error(`cannot read fleet manifest \`${abs}\``);
2562
+ }
2563
+ const manifest = {
2564
+ shape: "registry",
2565
+ manifestPath: abs,
2566
+ dir,
2567
+ corpHosts: [],
2568
+ labels: {},
2569
+ localDirs: {},
2570
+ localPath: path.join(dir, "registry.local.json"),
2571
+ localPresent: false,
2572
+ entries: [],
2573
+ problems: []
2574
+ };
2575
+ let parsed = null;
2576
+ try {
2577
+ parsed = asRecord(JSON.parse(raw));
2578
+ if (!parsed) {
2579
+ manifest.problems.push({
2580
+ kind: "parse-error",
2581
+ file: manifestFile,
2582
+ detail: "must contain a JSON object \u2014 no entries loaded"
2583
+ });
2584
+ return manifest;
2585
+ }
2586
+ } catch (err) {
2587
+ manifest.problems.push({
2588
+ kind: "parse-error",
2589
+ file: manifestFile,
2590
+ detail: `not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 no entries loaded`
2591
+ });
2592
+ return manifest;
2593
+ }
2594
+ if (Array.isArray(parsed.projects)) {
2595
+ manifest.shape = "registry";
2596
+ const local = await readLocal(manifest.localPath, manifest.problems);
2597
+ manifest.localPresent = local.present;
2598
+ manifest.localDirs = asStringMap(local.data.dirs);
2599
+ manifest.labels = asStringMap(local.data.labels);
2600
+ manifest.corpHosts = Array.isArray(local.data.corpHosts) ? local.data.corpHosts.filter((h) => typeof h === "string") : [];
2601
+ manifest.machineProfile = local.data.machineProfile === "corp" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
2602
+ const rootRaw = asString(local.data.root) ?? asString(parsed.root);
2603
+ if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
2604
+ loadRegistryEntries(parsed.projects, manifest, manifestFile);
2605
+ } else if (Array.isArray(parsed.sources)) {
2606
+ manifest.shape = "corpus";
2607
+ manifest.localPath = path.join(dir, "sources.local.json");
2608
+ const local = await readLocal(manifest.localPath, manifest.problems);
2609
+ manifest.localPresent = local.present;
2610
+ manifest.localDirs = asStringMap(local.data.dirs);
2611
+ loadCorpusEntries(parsed.sources, manifest, manifestFile);
2612
+ } else {
2613
+ manifest.problems.push({
2614
+ kind: "bad-shape",
2615
+ file: manifestFile,
2616
+ detail: "neither a `projects` (registry) nor a `sources` (corpus) array \u2014 no entries loaded"
2617
+ });
2618
+ }
2619
+ return manifest;
2620
+ }
2621
+ var pExecFile2 = promisify(execFile);
2622
+ var FLEET_LENS = "fleet-manifest";
2623
+ var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
2624
+ var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
2625
+ function finding3(id, tier, claim, evidence, why, action) {
2626
+ return {
2627
+ id,
2628
+ lens: FLEET_LENS,
2629
+ tier,
2630
+ claim,
2631
+ evidence,
2632
+ why,
2633
+ ...action ? { action } : {},
2634
+ effort: "S",
2635
+ confidence: "high"
2636
+ };
2637
+ }
2638
+ function corpPersistenceRoot(manifest, name) {
2639
+ const corpDir = path.join(manifest.dir, "corp");
2640
+ const root = path.resolve(corpDir, name);
2641
+ if (path.dirname(root) !== corpDir || root === corpDir) {
2642
+ throw new Error(
2643
+ `entry name ${JSON.stringify(name)} escapes the corp persistence zone \u2014 refusing to resolve a ledger root for it`
2644
+ );
2645
+ }
2646
+ return root;
2647
+ }
2648
+ function stateBudgetsFor(entry) {
2649
+ const overlay = {};
2650
+ if (entry.staleAfterDays !== void 0) overlay.staleAfterDays = entry.staleAfterDays;
2651
+ if (entry.stateBudget !== void 0) overlay.maxChars = entry.stateBudget;
2652
+ return Object.keys(overlay).length ? overlay : void 0;
2653
+ }
2654
+ async function gitGrepFiles(root, needle, mode = "fixed") {
2655
+ try {
2656
+ const matchFlag = mode === "regex" ? "-E" : "-F";
2657
+ const { stdout } = await pExecFile2("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
2658
+ cwd: root,
2659
+ timeout: 8e3
2660
+ });
2661
+ return stdout.split("\n").filter(Boolean);
2662
+ } catch (err) {
2663
+ if (err.code === 1) return [];
2664
+ return null;
2665
+ }
2666
+ }
2667
+ var MACHINE_PATH_RE = /\/Users\/[A-Za-z0-9._@-]{2,}([/"']|$)/m;
2668
+ var MACHINE_PATH_GREP = String.raw`/Users/[A-Za-z0-9._@-]{2,}(/|"|'|$)`;
2669
+ async function realpathOr(p) {
2670
+ try {
2671
+ return await promises.realpath(p);
2672
+ } catch {
2673
+ return path.resolve(p);
2674
+ }
2675
+ }
2676
+ function emailMatchesCorpHosts(email, hosts) {
2677
+ const domain = email.split("@")[1]?.toLowerCase();
2678
+ if (!domain) return false;
2679
+ return hosts.some((h) => {
2680
+ const host = h.toLowerCase();
2681
+ if (domain === host) return true;
2682
+ if (domain.includes(".") && host.endsWith(`.${domain}`) && domain.split(".").length >= host.split(".").length - 1) {
2683
+ return true;
2684
+ }
2685
+ return domain.endsWith(`.${host}`);
2686
+ });
2687
+ }
2688
+ var PROBLEM_TIER = {
2689
+ "parse-error": "risk",
2690
+ "bad-shape": "risk",
2691
+ "local-missing": "gap"
2692
+ };
2693
+ async function checkManifest(manifest) {
2694
+ const findings = [];
2695
+ const disclosures = [];
2696
+ const manifestFile = path.basename(manifest.manifestPath);
2697
+ const localFile = path.basename(manifest.localPath);
2698
+ for (const p of manifest.problems) {
2699
+ findings.push(
2700
+ finding3(
2701
+ `${FLEET_LENS}/${p.kind}:${p.file}`,
2702
+ PROBLEM_TIER[p.kind] ?? "gap",
2703
+ `${p.file}: ${p.detail}`,
2704
+ [p.file],
2705
+ "A manifest that cannot be read is a fleet whose claims cannot be verified \u2014 every consumer is flying blind.",
2706
+ "Fix the file; the manifest pair is the fleet's single source of truth."
2707
+ )
2708
+ );
2709
+ }
2710
+ const seen = /* @__PURE__ */ new Set();
2711
+ const flaggedDup = /* @__PURE__ */ new Set();
2712
+ for (const entry of manifest.entries) {
2713
+ if (seen.has(entry.name) && !flaggedDup.has(entry.name)) {
2714
+ flaggedDup.add(entry.name);
2715
+ findings.push(
2716
+ finding3(
2717
+ `${FLEET_LENS}/duplicate-name:${entry.name}`,
2718
+ "gap",
2719
+ `\`${entry.name}\` is registered more than once`,
2720
+ [`${manifestFile}: ${entry.name} appears twice`],
2721
+ "Resolution is keyed by name \u2014 a duplicate makes every lookup, dismiss, and delta ambiguous.",
2722
+ "Rename one of the entries."
2723
+ )
2724
+ );
2725
+ }
2726
+ seen.add(entry.name);
2727
+ if (entry.private && entry.path) {
2728
+ findings.push(
2729
+ finding3(
2730
+ `${FLEET_LENS}/private-path-leak:${entry.name}`,
2731
+ "risk",
2732
+ `private entry \`${entry.name}\` carries a \`path\` in the tracked manifest`,
2733
+ [`${manifestFile}: ${entry.name}.path = ${entry.path}`],
2734
+ "A private entry is an opaque alias by design \u2014 a tracked path ties the alias to a real directory for anyone who can read the manifest.",
2735
+ `Delete the path; map the alias in the gitignored ${localFile} instead.`
2736
+ )
2737
+ );
2738
+ }
2739
+ if (entry.resolvedRoot && !await isDirectory(entry.resolvedRoot)) {
2740
+ const viaLocal = manifest.localDirs[entry.name] !== void 0;
2741
+ findings.push(
2742
+ finding3(
2743
+ `${FLEET_LENS}/${viaLocal ? "dangling-dir" : "dangling-path"}:${entry.name}`,
2744
+ "gap",
2745
+ `\`${entry.name}\` resolves to a directory that does not exist`,
2746
+ [`${viaLocal ? localFile : manifestFile}: ${entry.name} \u2192 ${entry.resolvedRoot}`],
2747
+ "A dangling mapping silently drops the project from every sweep \u2014 absent coverage that looks registered.",
2748
+ "Fix the mapping or retire the entry."
2749
+ )
2750
+ );
2751
+ }
2752
+ if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "corp" || entry.private)) {
2753
+ findings.push(
2754
+ finding3(
2755
+ `${FLEET_LENS}/dangling-dir:${entry.name}`,
2756
+ "gap",
2757
+ `\`${entry.name}\` has no usable directory mapping`,
2758
+ [`${localFile}: ${entry.unresolved}`],
2759
+ "An unmapped corp entry silently drops out of every sweep on the machine that most needs it.",
2760
+ `Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
2761
+ )
2762
+ );
2763
+ }
2764
+ }
2765
+ for (const entry of manifest.entries) {
2766
+ for (const [rel, target] of Object.entries(entry.links)) {
2767
+ if (!seen.has(target)) {
2768
+ findings.push(
2769
+ finding3(
2770
+ `${FLEET_LENS}/dangling-link:${entry.name}:${target}`,
2771
+ "gap",
2772
+ `\`${entry.name}\` links \`${rel}\` \u2192 \`${target}\`, which is not a registered name`,
2773
+ [`${manifestFile}: ${entry.name}.links.${rel} = ${target}`],
2774
+ "Links are the metadata that keeps moves lossless \u2014 a dead target is a relationship claim nothing can follow.",
2775
+ "Point the link at a registered name or delete it."
2776
+ )
2777
+ );
2778
+ }
2779
+ }
2780
+ }
2781
+ for (const name of Object.keys(manifest.localDirs)) {
2782
+ if (manifest.shape === "registry" && !seen.has(name)) {
2783
+ findings.push(
2784
+ finding3(
2785
+ `${FLEET_LENS}/orphan-dir:${name}`,
2786
+ "gap",
2787
+ `${localFile} maps \`${name}\`, which is not a registered name`,
2788
+ [`${localFile}: dirs.${name}`],
2789
+ "An orphaned mapping is a ghost entry \u2014 it looks covered and is swept by nothing.",
2790
+ "Register the entry or delete the mapping."
2791
+ )
2792
+ );
2793
+ }
2794
+ }
2795
+ const rawManifest = await readText(manifest.manifestPath);
2796
+ if (rawManifest === null) {
2797
+ disclosures.push(`${manifestFile} could not be re-read for the machine-path check \u2014 skipped.`);
2798
+ } else if (MACHINE_PATH_RE.test(rawManifest)) {
2799
+ findings.push(
2800
+ finding3(
2801
+ `${FLEET_LENS}/registry-machine-path:${manifestFile}`,
2802
+ "risk",
2803
+ `${manifestFile} contains an absolute /Users/ path`,
2804
+ [manifestFile],
2805
+ "The tracked manifest is publishable by construction \u2014 a home-directory path ships the machine's identity with it.",
2806
+ `Use \`~\` (expanded consumer-side) or a root-relative path; machine paths belong in ${localFile}.`
2807
+ )
2808
+ );
2809
+ }
2810
+ if (manifest.machineProfile === "personal") {
2811
+ const corpCount = manifest.entries.filter((e) => e.profile === "corp" || e.private).length;
2812
+ if (corpCount) {
2813
+ disclosures.push(
2814
+ `machineProfile "personal": ${corpCount} corp entr${corpCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
2815
+ );
2816
+ }
2817
+ }
2818
+ return { findings, disclosures };
2819
+ }
2820
+ async function checkCorpWallArtifacts(entries, findings) {
2821
+ for (const entry of entries) {
2822
+ if (entry.profile !== "corp" || !entry.resolvedRoot) continue;
2823
+ for (const artifact of CORP_WALL_ARTIFACTS) {
2824
+ if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
2825
+ findings.push(
2826
+ finding3(
2827
+ `${FLEET_LENS}/corp-artifact-in-repo:${entry.name}:${artifact}`,
2828
+ "risk",
2829
+ `corp entry \`${entry.name}\` carries ${artifact} inside its worktree`,
2830
+ [`${entry.resolvedRoot}/${artifact}`],
2831
+ "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.",
2832
+ `Move it to the fleet's corp/${entry.name}/ zone and leave a pointer if the harness needs one.`
2833
+ )
2834
+ );
2835
+ }
2836
+ }
2837
+ }
2838
+ }
2839
+ async function checkCoverage(manifest, findings, disclosures) {
2840
+ if (!manifest.corpHosts.length) {
2841
+ disclosures.push(
2842
+ "Coverage check skipped \u2014 no corpHosts in the local manifest, so a corp remote cannot be recognized."
2843
+ );
2844
+ return;
2845
+ }
2846
+ if (!manifest.root || !await isDirectory(manifest.root)) {
2847
+ disclosures.push("Coverage check skipped \u2014 the fleet root is undeclared or missing.");
2848
+ return;
2849
+ }
2850
+ const registered = /* @__PURE__ */ new Set();
2851
+ for (const entry of manifest.entries) {
2852
+ if (entry.resolvedRoot) registered.add(await realpathOr(entry.resolvedRoot));
2853
+ }
2854
+ registered.add(await realpathOr(manifest.dir));
2855
+ const dirents = await promises.readdir(manifest.root, { withFileTypes: true });
2856
+ for (const d of dirents) {
2857
+ if (!d.isDirectory() || d.name.startsWith(".")) continue;
2858
+ const dir = path.join(manifest.root, d.name);
2859
+ if (registered.has(await realpathOr(dir))) continue;
2860
+ if (!await pathExists(path.join(dir, ".git"))) continue;
2861
+ const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
2862
+ if (!remotes) continue;
2863
+ const corpRemote = manifest.corpHosts.find((h) => remotes.includes(h));
2864
+ if (corpRemote) {
2865
+ findings.push(
2866
+ finding3(
2867
+ `${FLEET_LENS}/unregistered-corp-remote:${d.name}`,
2868
+ "risk",
2869
+ `\`${d.name}\` under the fleet root has a corp remote but is not registered`,
2870
+ [`${dir}: remote matches ${corpRemote}`],
2871
+ "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.",
2872
+ "Register it as a corp entry (opaque alias + local dirs mapping) or move it out of the fleet root."
2873
+ )
2874
+ );
2875
+ }
2876
+ }
2877
+ }
2878
+ async function checkManifestRepoMachinePaths(manifest, findings, disclosures) {
2879
+ const isRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
2880
+ if (!isRepo) {
2881
+ disclosures.push(
2882
+ "Machine-path check skipped \u2014 the manifest directory is not inside a git repository."
2883
+ );
2884
+ return;
2885
+ }
2886
+ const files = await gitGrepFiles(manifest.dir, MACHINE_PATH_GREP, "regex");
2887
+ if (files === null) {
2888
+ disclosures.push(
2889
+ "Machine-path check could not run (`git grep` failed) \u2014 undetermined, not clean."
2890
+ );
2891
+ return;
2892
+ }
2893
+ for (const file of files) {
2894
+ findings.push(
2895
+ finding3(
2896
+ `${FLEET_LENS}/machine-path:${file}`,
2897
+ "risk",
2898
+ `${file} (tracked in the manifest's repo) contains an absolute /Users/ path`,
2899
+ [file],
2900
+ "The manifest repo is the fleet's publishable surface \u2014 a tracked home-directory path ships the machine's identity in it.",
2901
+ "Replace with `~` or a relative path; machine paths belong only in gitignored local files."
2902
+ )
2903
+ );
2904
+ }
2905
+ }
2906
+ async function checkHygieneNeedles(manifest, findings, disclosures) {
2907
+ const publicEntries = manifest.entries.filter((e) => e.trust === "public-repo" && e.resolvedRoot);
2908
+ if (!publicEntries.length) return;
2909
+ if (!manifest.localPresent) {
2910
+ disclosures.push(
2911
+ "Hygiene-needle check skipped \u2014 the local manifest (needle source) is absent on this machine."
2912
+ );
2913
+ return;
2914
+ }
2915
+ const needles = [
2916
+ ...new Set(
2917
+ [
2918
+ ...Object.values(manifest.labels),
2919
+ ...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
2920
+ ...manifest.corpHosts
2921
+ ].filter((n) => n.length >= 3)
2922
+ )
2923
+ ];
2924
+ if (!needles.length) {
2925
+ disclosures.push("Hygiene-needle check skipped \u2014 the local manifest carries no needles.");
2926
+ return;
2927
+ }
2928
+ for (const entry of publicEntries) {
2929
+ const flagged = /* @__PURE__ */ new Set();
2930
+ for (const needle of needles) {
2931
+ const files = await gitGrepFiles(entry.resolvedRoot, needle);
2932
+ if (files === null) {
2933
+ disclosures.push(
2934
+ `Hygiene-needle check could not run in \`${entry.name}\` \u2014 undetermined, not clean.`
2935
+ );
2936
+ break;
2937
+ }
2938
+ for (const file of files) {
2939
+ if (flagged.has(file)) continue;
2940
+ flagged.add(file);
2941
+ findings.push(
2942
+ finding3(
2943
+ `${FLEET_LENS}/hygiene-needle:${entry.name}:${file}`,
2944
+ "risk",
2945
+ `${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
2946
+ [`${file}: contains "${needle}"`],
2947
+ "A public repo that names a private label, corp directory, or corp host joins the public and employer identities for anyone who greps.",
2948
+ "Scrub the needle from the tracked file (rewrite history if it already shipped)."
2949
+ )
2950
+ );
2951
+ }
2952
+ }
2953
+ }
2954
+ }
2955
+ function expandNeedleDir(dir) {
2956
+ return dir.replace(/\/+$/, "");
2957
+ }
2958
+ async function checkCorpEmails(manifest, findings, disclosures) {
2959
+ if (!manifest.corpHosts.length) {
2960
+ disclosures.push(
2961
+ "Corp-email check skipped \u2014 no corpHosts in the local manifest, so a corp domain cannot be recognized."
2962
+ );
2963
+ return;
2964
+ }
2965
+ for (const entry of manifest.entries) {
2966
+ if (entry.profile !== "personal" || !entry.resolvedRoot) continue;
2967
+ const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
2968
+ if (emails === null) {
2969
+ disclosures.push(
2970
+ `Corp-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
2971
+ );
2972
+ continue;
2973
+ }
2974
+ const lines = emails.split("\n").filter(Boolean);
2975
+ const matched = [...new Set(lines.filter((e) => emailMatchesCorpHosts(e, manifest.corpHosts)))];
2976
+ if (matched.length) {
2977
+ const count = lines.filter((e) => matched.includes(e)).length;
2978
+ findings.push(
2979
+ finding3(
2980
+ `${FLEET_LENS}/corp-email:${entry.name}`,
2981
+ "risk",
2982
+ `recent commits on personal entry \`${entry.name}\` are authored with a corp-host email`,
2983
+ [`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
2984
+ "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.",
2985
+ "Set the repo-local identity to the personal one and amend the unpushed commits."
2986
+ )
2987
+ );
2988
+ }
2989
+ }
2990
+ }
2991
+ async function collectWallFindings(manifest) {
2992
+ const findings = [];
2993
+ const disclosures = [];
2994
+ await checkCorpWallArtifacts(manifest.entries, findings);
2995
+ await checkCoverage(manifest, findings, disclosures);
2996
+ await checkManifestRepoMachinePaths(manifest, findings, disclosures);
2997
+ await checkHygieneNeedles(manifest, findings, disclosures);
2998
+ await checkCorpEmails(manifest, findings, disclosures);
2999
+ return { findings, disclosures };
3000
+ }
3001
+ function stateAgeDays(facts) {
3002
+ const freshness = facts.freshness;
3003
+ if (!freshness?.repoLastCommit) return null;
3004
+ const stateIds = new Set(
3005
+ facts.artifacts.filter((a) => a.kind === "state" && a.exists).map((a) => a.id)
3006
+ );
3007
+ const dated = freshness.artifacts.filter((f) => stateIds.has(f.artifactId));
3008
+ if (!dated.length) return null;
3009
+ const repoAt = Date.parse(freshness.repoLastCommit);
3010
+ return Math.max(
3011
+ 0,
3012
+ ...dated.map(
3013
+ (f) => (
3014
+ // A dirty artifact is treated fresh-now (the refresh is on disk, uncommitted) — the row
3015
+ // must agree with the lens, which discloses instead of flagging it.
3016
+ f.commitsSince && !f.dirty ? Math.floor((repoAt - Date.parse(f.lastCommit)) / 864e5) : 0
3017
+ )
3018
+ )
3019
+ );
3020
+ }
3021
+ async function sweepEntry(manifest, entry, opts) {
3022
+ const project = {
3023
+ name: entry.name,
3024
+ profile: entry.profile,
3025
+ kind: entry.kind,
3026
+ resolvedRoot: entry.resolvedRoot,
3027
+ unresolved: entry.unresolved,
3028
+ staleAfterDays: entry.staleAfterDays ?? DEFAULT_CONFIG.state.staleAfterDays,
3029
+ stateAgeDays: null,
3030
+ counts: { risk: 0, gap: 0, polish: 0 },
3031
+ findings: [],
3032
+ disclosures: []
3033
+ };
3034
+ const root = entry.resolvedRoot;
3035
+ if (!root) {
3036
+ project.disclosures.push(`not audited: ${entry.unresolved ?? "unresolved"}`);
3037
+ return project;
3038
+ }
3039
+ if (!await isDirectory(root)) {
3040
+ project.unresolved = `resolved directory does not exist: ${root}`;
3041
+ project.disclosures.push(`not audited: ${project.unresolved} \u2014 \`etymd fleet check\` names it`);
3042
+ return project;
3043
+ }
3044
+ let upstreamRemote;
3045
+ if (entry.upstream) {
3046
+ const url = await git(root, ["remote", "get-url", entry.upstream]);
3047
+ const refs = url !== null ? await git(root, ["for-each-ref", "--count=1", `refs/remotes/${entry.upstream}`]) : null;
3048
+ if (url !== null && refs) {
3049
+ upstreamRemote = entry.upstream;
3050
+ project.disclosures.push(
3051
+ `freshness measured on fork-authored commits only (\`--not --remotes=${entry.upstream}\`)`
3052
+ );
3053
+ } else if (url !== null) {
3054
+ project.disclosures.push(
3055
+ `upstream remote \`${entry.upstream}\` has no fetched refs \u2014 freshness measured on all commits`
3056
+ );
3057
+ } else {
3058
+ project.disclosures.push(
3059
+ `upstream remote \`${entry.upstream}\` not found \u2014 freshness measured on all commits`
3060
+ );
3061
+ }
3062
+ }
3063
+ const corp = entry.profile === "corp";
3064
+ const optedIn = !corp && await pathExists(path.join(root, ETYMD_DIR));
3065
+ const result = await runAudit(root, {
3066
+ kind: opts.kind,
3067
+ persistLedger: !corp && opts.persistLedgers === true && optedIn,
3068
+ readOnlyRoot: corp,
3069
+ ledgerRoot: corp ? corpPersistenceRoot(manifest, entry.name) : void 0,
3070
+ stateBudgets: stateBudgetsFor(entry),
3071
+ upstreamRemote
3072
+ });
3073
+ project.findings = result.findings;
3074
+ for (const f of result.findings) project.counts[f.tier] += 1;
3075
+ project.stateAgeDays = stateAgeDays(result.facts);
3076
+ project.staleAfterDays = result.config.config.state.staleAfterDays;
3077
+ for (const [key, value] of Object.entries(entry.contract)) {
3078
+ if (key === "placement" || !value) continue;
3079
+ const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
3080
+ project.disclosures.push(
3081
+ `contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
3082
+ );
3083
+ }
3084
+ if (entry.contract.placement === "none") {
3085
+ project.disclosures.push("contract placement `none` \u2014 legitimately absent, by declaration");
3086
+ }
3087
+ return project;
3088
+ }
3089
+ async function sweepFleet(manifest, opts = {}) {
3090
+ let entries = manifest.entries;
3091
+ if (opts.only?.length) {
3092
+ const known = new Set(entries.map((e) => e.name));
3093
+ const unknown = opts.only.filter((n) => !known.has(n));
3094
+ if (unknown.length) {
3095
+ throw new Error(
3096
+ `--only names not in the manifest: ${unknown.join(", ")} \u2014 run \`etymd fleet check\``
3097
+ );
3098
+ }
3099
+ const wanted = new Set(opts.only);
3100
+ entries = entries.filter((e) => wanted.has(e.name));
3101
+ }
3102
+ if (opts.profile) entries = entries.filter((e) => e.profile === opts.profile);
3103
+ const projects = [];
3104
+ for (const entry of entries) {
3105
+ projects.push(await sweepEntry(manifest, entry, opts));
3106
+ }
3107
+ const wall = await collectWallFindings(manifest);
3108
+ return {
3109
+ schema: FLEET_JSON_SCHEMA,
3110
+ manifest: manifest.manifestPath,
3111
+ sweptAt: (/* @__PURE__ */ new Date()).toISOString(),
3112
+ projects,
3113
+ wall: wall.findings,
3114
+ wallDisclosures: wall.disclosures,
3115
+ outOfScope: projects.filter((p) => !p.resolvedRoot || p.unresolved).map((p) => p.name),
3116
+ problems: manifest.problems.map((p) => `${p.file}: ${p.detail}`)
3117
+ };
3118
+ }
2094
3119
 
2095
- export { CONFIG_FILE, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, LENSES, NAME, PACK_VERSION, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, cacheFactsPath, configPath, contextEconomyLens, deriveProfile, extractCommandClaims, extractPathClaims, gateIntegrityLens, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, measureContext, planWorkflow, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, runAudit, scanProject, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };
3120
+ export { CONFIG_FILE, DECISIONS_FORMAT_MARKER, DEFAULT_CONFIG, EXTRACTION_THRESHOLD, FLEET_JSON_SCHEMA, FLEET_LENS, LENSES, NAME, PACK_VERSION, TOTAL_BUDGET_WORDS, VERSION, applyFiles, baselineCarriesMachinePath, baselinePath, buildGateInventory, cacheFactsPath, checkManifest, collectWallFindings, configPath, contextEconomyLens, corpPersistenceRoot, deriveProfile, expandTilde, extractCommandClaims, extractPathClaims, gateIntegrityLens, instructionTruthLens, isAlwaysAppliedCursorRule, listInstructionFiles, loadFleetManifest, measureContext, planWorkflow, rankFindings, readBaseline, readCachedFacts, readConfig, readLedger, reconcileLedger, runAudit, scanProject, stateFreshnessLens, sweepFleet, withoutMachinePath, writeBaseline, writeCachedFacts, writeLedger };