etymd 0.7.0 → 0.9.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
@@ -30,7 +30,7 @@ var init_package = __esm({
30
30
  "package.json"() {
31
31
  package_default = {
32
32
  name: "etymd",
33
- version: "0.7.0",
33
+ version: "0.9.0",
34
34
  description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
35
35
  keywords: [
36
36
  "cli",
@@ -924,7 +924,7 @@ async function readConfig(root) {
924
924
  const target = configPath(root);
925
925
  const problems = [];
926
926
  if (!await pathExists(target)) {
927
- return { config: DEFAULT_CONFIG, present: false, problems };
927
+ return { config: DEFAULT_CONFIG, present: false, problems, explicit: { gatesFailOn: false } };
928
928
  }
929
929
  const raw = await readText(target);
930
930
  let parsed;
@@ -934,11 +934,11 @@ async function readConfig(root) {
934
934
  problems.push(
935
935
  `${CONFIG_FILE} exists but is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 defaults used.`
936
936
  );
937
- return { config: DEFAULT_CONFIG, present: true, problems };
937
+ return { config: DEFAULT_CONFIG, present: true, problems, explicit: { gatesFailOn: false } };
938
938
  }
939
939
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
940
940
  problems.push(`${CONFIG_FILE} must contain a JSON object \u2014 defaults used.`);
941
- return { config: DEFAULT_CONFIG, present: true, problems };
941
+ return { config: DEFAULT_CONFIG, present: true, problems, explicit: { gatesFailOn: false } };
942
942
  }
943
943
  const obj = parsed;
944
944
  const instructions = obj.instructions ?? {};
@@ -964,6 +964,7 @@ async function readConfig(root) {
964
964
  return {
965
965
  present: true,
966
966
  problems,
967
+ explicit: { gatesFailOn: failOn !== void 0 },
967
968
  config: {
968
969
  instructions: {
969
970
  include: readGlobList(instructions.include, "instructions.include", problems) ?? DEFAULT_CONFIG.instructions.include,
@@ -1285,8 +1286,10 @@ var init_templates = __esm({
1285
1286
  // src/core/generate.ts
1286
1287
  var generate_exports = {};
1287
1288
  __export(generate_exports, {
1289
+ deriveFailOn: () => deriveFailOn,
1288
1290
  derivedCommands: () => derivedCommands,
1289
- planWorkflow: () => planWorkflow
1291
+ planWorkflow: () => planWorkflow,
1292
+ riskReachability: () => riskReachability
1290
1293
  });
1291
1294
  function derivedCommands(facts, existingHook) {
1292
1295
  const c = facts.commands;
@@ -1298,6 +1301,24 @@ function derivedCommands(facts, existingHook) {
1298
1301
  }
1299
1302
  return base;
1300
1303
  }
1304
+ function riskReachability(facts) {
1305
+ const reasons = [];
1306
+ if (facts.publishRoute !== "none") {
1307
+ reasons.push("an instruction file can name a package script that no longer exists");
1308
+ }
1309
+ if (facts.artifacts.some((a) => a.kind === "state" && a.exists)) {
1310
+ reasons.push("a state doc can fall far enough behind the repo to escalate");
1311
+ }
1312
+ return reasons;
1313
+ }
1314
+ function deriveFailOn(facts, recorded) {
1315
+ const reachable = riskReachability(facts);
1316
+ if (recorded.explicit) return { failOn: recorded.failOn, source: "config", reachable };
1317
+ if (recorded.failOn === "risk" && reachable.length === 0) {
1318
+ return { failOn: "gap", source: "derived", reachable };
1319
+ }
1320
+ return { failOn: recorded.failOn, source: "default", reachable };
1321
+ }
1301
1322
  async function planWorkflow(root, facts, opts) {
1302
1323
  const out = [];
1303
1324
  const add = async (rel, contents, label, executable = false) => {
@@ -2100,6 +2121,285 @@ init_config();
2100
2121
  init_facts();
2101
2122
  init_util();
2102
2123
 
2124
+ // src/lenses/state-freshness.ts
2125
+ init_config();
2126
+ init_util();
2127
+ var LENS_ID3 = "state-freshness";
2128
+ var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
2129
+ var KNOWN_FORMAT_VERSION = 1;
2130
+ var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
2131
+ var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
2132
+ var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
2133
+ var MS_PER_DAY = 864e5;
2134
+ function parseDecisionsFormat(text) {
2135
+ const m = MARKER_RE.exec(text);
2136
+ if (!m) return null;
2137
+ const problems = [];
2138
+ const fields = [];
2139
+ const offset = m.index ?? 0;
2140
+ const version = Number(m[1]);
2141
+ if (version !== KNOWN_FORMAT_VERSION) {
2142
+ problems.push(
2143
+ `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
2144
+ );
2145
+ }
2146
+ const attrs = (m[2] ?? "").trim();
2147
+ if (!attrs) return { fields, offset, problems };
2148
+ const declared = /^fields=(.*)$/.exec(attrs);
2149
+ if (!declared) {
2150
+ problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
2151
+ return { fields, offset, problems };
2152
+ }
2153
+ const seen = new Set(BUILT_IN_FIELDS);
2154
+ for (const raw of declared[1].split(",")) {
2155
+ const name = raw.trim();
2156
+ if (!name) continue;
2157
+ if (!FIELD_NAME_RE.test(name)) {
2158
+ problems.push(
2159
+ `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
2160
+ );
2161
+ continue;
2162
+ }
2163
+ const key = name.toLowerCase();
2164
+ if (seen.has(key)) continue;
2165
+ seen.add(key);
2166
+ fields.push(name);
2167
+ }
2168
+ if (fields.length === 0 && problems.length === 0) {
2169
+ problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
2170
+ }
2171
+ return { fields, offset, problems };
2172
+ }
2173
+ function hasField(block, name) {
2174
+ return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
2175
+ }
2176
+ function parseDecisionEntries(text) {
2177
+ const headings = [...text.matchAll(/^## .*$/gm)];
2178
+ const entries = [];
2179
+ for (let i = 0; i < headings.length; i++) {
2180
+ const h = headings[i];
2181
+ const m = /^## (D-(\d+))\b/.exec(h[0]);
2182
+ if (!m) continue;
2183
+ const offset = h.index ?? 0;
2184
+ const start = offset + h[0].length;
2185
+ const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
2186
+ entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
2187
+ }
2188
+ return entries;
2189
+ }
2190
+ function checkIdSequence(file, entries) {
2191
+ const findings = [];
2192
+ const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
2193
+ const seen = /* @__PURE__ */ new Map();
2194
+ const duplicated = /* @__PURE__ */ new Set();
2195
+ let prev;
2196
+ for (const entry of entries) {
2197
+ if (seen.has(entry.num)) {
2198
+ if (!duplicated.has(entry.num)) {
2199
+ duplicated.add(entry.num);
2200
+ findings.push({
2201
+ id: `${LENS_ID3}/duplicate-id:${file}:${entry.id}`,
2202
+ lens: LENS_ID3,
2203
+ tier: "gap",
2204
+ claim: `${file} carries more than one ${entry.id} entry`,
2205
+ evidence: [`${file}: ${entry.id} appears twice`],
2206
+ why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
2207
+ action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
2208
+ effort: "S",
2209
+ confidence: "high"
2210
+ });
2211
+ }
2212
+ } else {
2213
+ seen.set(entry.num, entry);
2214
+ if (prev && entry.num < prev.num) {
2215
+ findings.push({
2216
+ id: `${LENS_ID3}/id-order:${file}:${entry.id}`,
2217
+ lens: LENS_ID3,
2218
+ tier: "gap",
2219
+ claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
2220
+ evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
2221
+ 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.",
2222
+ action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
2223
+ effort: "S",
2224
+ confidence: "high"
2225
+ });
2226
+ }
2227
+ prev = entry;
2228
+ }
2229
+ }
2230
+ return findings;
2231
+ }
2232
+ function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
2233
+ const findings = [];
2234
+ for (const entry of entries) {
2235
+ const bound = entry.offset >= markerOffset;
2236
+ for (const field of bound ? declaredFields : []) {
2237
+ if (hasField(entry.block, field)) continue;
2238
+ findings.push({
2239
+ id: `${LENS_ID3}/field-missing:${file}:${entry.id}:${field}`,
2240
+ lens: LENS_ID3,
2241
+ tier: "gap",
2242
+ claim: `${file} ${entry.id} has no ${field}: field`,
2243
+ evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
2244
+ why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
2245
+ action: `Add a ${field}: line to ${entry.id}.`,
2246
+ effort: "S",
2247
+ confidence: "high"
2248
+ });
2249
+ }
2250
+ if (bound && !/Scope[\s*]*:/.test(entry.block)) {
2251
+ findings.push({
2252
+ id: `${LENS_ID3}/scope-missing:${file}:${entry.id}`,
2253
+ lens: LENS_ID3,
2254
+ tier: "gap",
2255
+ claim: `${file} ${entry.id} has no Scope: field`,
2256
+ evidence: [`${file}: ${entry.id}`],
2257
+ why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
2258
+ action: "Add a Scope: line naming what the decision binds.",
2259
+ effort: "S",
2260
+ confidence: "high"
2261
+ });
2262
+ }
2263
+ const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
2264
+ if (revisit && revisit[1] < today) {
2265
+ findings.push({
2266
+ id: `${LENS_ID3}/revisit-due:${file}:${entry.id}`,
2267
+ lens: LENS_ID3,
2268
+ tier: "gap",
2269
+ claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
2270
+ evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
2271
+ why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
2272
+ action: "Re-evaluate the decision: supersede it or move the Revisit date.",
2273
+ effort: "S",
2274
+ confidence: "high"
2275
+ });
2276
+ }
2277
+ }
2278
+ return findings;
2279
+ }
2280
+ var stateFreshnessLens = {
2281
+ id: LENS_ID3,
2282
+ version: "1",
2283
+ title: "State freshness",
2284
+ kind: "truth",
2285
+ async run(ctx) {
2286
+ const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
2287
+ const findings = [];
2288
+ const disclosures = [...ctx.config?.problems ?? []];
2289
+ const outOfScope = [];
2290
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2291
+ const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
2292
+ const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
2293
+ if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
2294
+ disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
2295
+ }
2296
+ const freshness = ctx.facts.freshness;
2297
+ if (!freshness) {
2298
+ disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
2299
+ } else {
2300
+ for (const u of freshness.unverifiable) {
2301
+ disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
2302
+ }
2303
+ for (const a of stateArtifacts) {
2304
+ const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
2305
+ if (!fact || !freshness.repoLastCommit) continue;
2306
+ if (fact.dirty) {
2307
+ disclosures.push(
2308
+ `${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
2309
+ );
2310
+ continue;
2311
+ }
2312
+ if (!fact.commitsSince) continue;
2313
+ const gapDays = Math.floor(
2314
+ (Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
2315
+ );
2316
+ if (gapDays <= budgets.staleAfterDays) continue;
2317
+ const escalated = gapDays > budgets.staleAfterDays * 3;
2318
+ findings.push({
2319
+ id: `${LENS_ID3}/stale-state:${a.path}`,
2320
+ lens: LENS_ID3,
2321
+ tier: escalated ? "risk" : "gap",
2322
+ claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
2323
+ evidence: [
2324
+ `${a.path} last commit: ${fact.lastCommit}`,
2325
+ `repo last commit: ${freshness.repoLastCommit}`
2326
+ ],
2327
+ 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.`,
2328
+ action: "Refresh the state doc (or record why it is still current).",
2329
+ effort: "S",
2330
+ confidence: "high"
2331
+ });
2332
+ }
2333
+ }
2334
+ for (const a of stateArtifacts) {
2335
+ const text = await readText(path.join(ctx.root, a.path));
2336
+ if (text === null) {
2337
+ disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
2338
+ outOfScope.push(a.path);
2339
+ continue;
2340
+ }
2341
+ if (text.length > budgets.maxChars) {
2342
+ findings.push({
2343
+ id: `${LENS_ID3}/state-over-budget:${a.path}`,
2344
+ lens: LENS_ID3,
2345
+ tier: "gap",
2346
+ claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
2347
+ evidence: [`${a.path}: ${text.length} chars`],
2348
+ 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.",
2349
+ action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
2350
+ effort: "M",
2351
+ confidence: "high"
2352
+ });
2353
+ }
2354
+ }
2355
+ for (const a of decisionArtifacts) {
2356
+ const text = await readText(path.join(ctx.root, a.path));
2357
+ if (text === null) {
2358
+ disclosures.push(
2359
+ `${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
2360
+ );
2361
+ continue;
2362
+ }
2363
+ const entries = parseDecisionEntries(text);
2364
+ findings.push(...checkIdSequence(a.path, entries));
2365
+ const format = parseDecisionsFormat(text);
2366
+ if (!format) {
2367
+ disclosures.push(
2368
+ `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
2369
+ );
2370
+ outOfScope.push(a.path);
2371
+ continue;
2372
+ }
2373
+ for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
2374
+ const exempt = entries.filter((e) => e.offset < format.offset);
2375
+ if (format.fields.length > 0) {
2376
+ disclosures.push(
2377
+ `${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry at or after the marker (etymd attaches no meaning to the names).`
2378
+ );
2379
+ }
2380
+ if (exempt.length > 0) {
2381
+ disclosures.push(
2382
+ `${a.path}: ${exempt.length} entr${exempt.length === 1 ? "y" : "ies"} precede the format marker (${exempt[0]?.id}\u2026${exempt[exempt.length - 1]?.id}) \u2014 field presence not checked there (forward-only from the marker's position).`
2383
+ );
2384
+ }
2385
+ findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
2386
+ }
2387
+ disclosures.push(
2388
+ `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.`
2389
+ );
2390
+ return {
2391
+ lens: LENS_ID3,
2392
+ version: "1",
2393
+ title: "State freshness",
2394
+ kind: "truth",
2395
+ status: "ran",
2396
+ disclosures,
2397
+ findings,
2398
+ ...outOfScope.length ? { outOfScope } : {}
2399
+ };
2400
+ }
2401
+ };
2402
+
2103
2403
  // src/lenses/instruction-truth/claims.ts
2104
2404
  init_detect();
2105
2405
  init_util();
@@ -2160,6 +2460,15 @@ async function listInstructionFiles(root, facts, scope) {
2160
2460
  }
2161
2461
  return { files: kept, excluded, included };
2162
2462
  }
2463
+ async function listStateDocuments(root, facts) {
2464
+ const docs = [];
2465
+ for (const artifact of facts.artifacts) {
2466
+ if (artifact.kind !== "state" || !artifact.exists) continue;
2467
+ const text = await readText(path.join(root, artifact.path));
2468
+ if (text !== null) docs.push({ path: normalizeRelPath(artifact.path), text });
2469
+ }
2470
+ return docs;
2471
+ }
2163
2472
  function extractCodeTokens(text) {
2164
2473
  const tokens = [];
2165
2474
  for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
@@ -2297,6 +2606,30 @@ function extractPathClaims(text) {
2297
2606
  }
2298
2607
  return { paths, prospective, placeholder: [...placeholder] };
2299
2608
  }
2609
+ var LOCAL_REF_LEADINS = new Set(
2610
+ "decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
2611
+ );
2612
+ function extractDecisionRefs(text) {
2613
+ const byNum = /* @__PURE__ */ new Map();
2614
+ for (const m of text.matchAll(/\bD-(\d{1,4})\b/g)) {
2615
+ const index = m.index ?? 0;
2616
+ if (index > 0 && /[-/_.]/.test(text[index - 1])) continue;
2617
+ const before = text.slice(Math.max(0, index - 48), index);
2618
+ const lead = /([A-Za-z][A-Za-z0-9'’-]*)[ \t]+$/.exec(before)?.[1];
2619
+ const local = !lead || LOCAL_REF_LEADINS.has(lead.toLowerCase());
2620
+ const num = Number(m[1]);
2621
+ const seen = byNum.get(num);
2622
+ if (!seen) byNum.set(num, { asWritten: m[0], local });
2623
+ else seen.local = seen.local || local;
2624
+ }
2625
+ const refs = /* @__PURE__ */ new Map();
2626
+ let qualifiedSkipped = 0;
2627
+ for (const [num, ref] of byNum) {
2628
+ if (ref.local) refs.set(num, ref.asWritten);
2629
+ else qualifiedSkipped += 1;
2630
+ }
2631
+ return { refs, qualifiedSkipped };
2632
+ }
2300
2633
  function packageManagerUsage(text) {
2301
2634
  const counts = /* @__PURE__ */ new Map();
2302
2635
  for (const token of extractCodeTokens(text)) {
@@ -2319,10 +2652,10 @@ function extractDocRefs(text) {
2319
2652
  }
2320
2653
 
2321
2654
  // src/lenses/instruction-truth/lens.ts
2322
- var LENS_ID3 = "instruction-truth";
2655
+ var LENS_ID4 = "instruction-truth";
2323
2656
  var MAX_PATH_FINDINGS_PER_FILE = 15;
2324
2657
  function finding2(partial) {
2325
- return { lens: LENS_ID3, ...partial };
2658
+ return { lens: LENS_ID4, ...partial };
2326
2659
  }
2327
2660
  function compareCommands(baseline, fresh) {
2328
2661
  const out = [];
@@ -2331,7 +2664,7 @@ function compareCommands(baseline, fresh) {
2331
2664
  if (before && !(before in fresh.commands.raw)) {
2332
2665
  out.push(
2333
2666
  finding2({
2334
- id: `${LENS_ID3}/command-gone-${role}`,
2667
+ id: `${LENS_ID4}/command-gone-${role}`,
2335
2668
  tier: "risk",
2336
2669
  claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
2337
2670
  evidence: ["package.json"],
@@ -2353,7 +2686,7 @@ function compareArtifacts(baseline, fresh) {
2353
2686
  if (a.exists && now && !now.exists) {
2354
2687
  out.push(
2355
2688
  finding2({
2356
- id: `${LENS_ID3}/artifact-gone-${a.id}`,
2689
+ id: `${LENS_ID4}/artifact-gone-${a.id}`,
2357
2690
  tier: "gap",
2358
2691
  claim: `${a.label} was present at baseline but is now missing`,
2359
2692
  evidence: [a.path],
@@ -2371,7 +2704,7 @@ function compareLayout(baseline, fresh) {
2371
2704
  const now = new Set(fresh.tree.dirs.map((d) => d.name));
2372
2705
  return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
2373
2706
  (d) => finding2({
2374
- id: `${LENS_ID3}/dir-gone-${d.name}`,
2707
+ id: `${LENS_ID4}/dir-gone-${d.name}`,
2375
2708
  tier: "gap",
2376
2709
  claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
2377
2710
  evidence: [`${d.name}/`],
@@ -2383,7 +2716,7 @@ function compareLayout(baseline, fresh) {
2383
2716
  );
2384
2717
  }
2385
2718
  var instructionTruthLens = {
2386
- id: LENS_ID3,
2719
+ id: LENS_ID4,
2387
2720
  version: "1",
2388
2721
  title: "Instruction truth",
2389
2722
  kind: "truth",
@@ -2401,7 +2734,7 @@ var instructionTruthLens = {
2401
2734
  if (!files.length) {
2402
2735
  findings.push(
2403
2736
  finding2({
2404
- id: `${LENS_ID3}/no-contract`,
2737
+ id: `${LENS_ID4}/no-contract`,
2405
2738
  tier: "gap",
2406
2739
  claim: "No agent instruction files exist (AGENTS.md or equivalents)",
2407
2740
  evidence: ["AGENTS.md (missing)"],
@@ -2436,13 +2769,14 @@ var instructionTruthLens = {
2436
2769
  return false;
2437
2770
  };
2438
2771
  const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
2772
+ const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
2439
2773
  let totalFilteredSkipped = 0;
2440
2774
  let binaryResolved = 0;
2441
2775
  let unverifiableCommands = 0;
2442
2776
  let gitignoredSkipped = 0;
2443
2777
  let prospectiveSkipped = 0;
2444
2778
  let placeholderSkipped = 0;
2445
- for (const file of files) {
2779
+ const auditClaims = async (file) => {
2446
2780
  const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
2447
2781
  totalFilteredSkipped += filteredSkipped;
2448
2782
  for (const [script, raw] of claimed) {
@@ -2451,13 +2785,13 @@ var instructionTruthLens = {
2451
2785
  binaryResolved += 1;
2452
2786
  continue;
2453
2787
  }
2454
- if (!nodeModulesInstalled) {
2788
+ if (!nodeModulesInstalled && manifestExists) {
2455
2789
  unverifiableCommands += 1;
2456
2790
  continue;
2457
2791
  }
2458
2792
  findings.push(
2459
2793
  finding2({
2460
- id: `${LENS_ID3}/stale-command:${file.path}:${script}`,
2794
+ id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
2461
2795
  tier: "risk",
2462
2796
  claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
2463
2797
  evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
@@ -2492,7 +2826,7 @@ var instructionTruthLens = {
2492
2826
  pathFindings += 1;
2493
2827
  findings.push(
2494
2828
  finding2({
2495
- id: `${LENS_ID3}/stale-path:${file.path}:${claim}`,
2829
+ id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
2496
2830
  tier: "gap",
2497
2831
  claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
2498
2832
  evidence: [file.path, `missing: ${claim}`],
@@ -2503,6 +2837,9 @@ var instructionTruthLens = {
2503
2837
  })
2504
2838
  );
2505
2839
  }
2840
+ };
2841
+ for (const file of files) {
2842
+ await auditClaims(file);
2506
2843
  if (facts.packageManager !== "unknown") {
2507
2844
  const usage = packageManagerUsage(file.text);
2508
2845
  const own = usage.get(facts.packageManager) ?? 0;
@@ -2510,7 +2847,7 @@ var instructionTruthLens = {
2510
2847
  if (pm === facts.packageManager || count < 2 || count <= own) continue;
2511
2848
  findings.push(
2512
2849
  finding2({
2513
- id: `${LENS_ID3}/pm-conflict:${file.path}`,
2850
+ id: `${LENS_ID4}/pm-conflict:${file.path}`,
2514
2851
  tier: "gap",
2515
2852
  claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
2516
2853
  evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
@@ -2527,7 +2864,7 @@ var instructionTruthLens = {
2527
2864
  if (await pathExists(path.join(root, ref))) continue;
2528
2865
  findings.push(
2529
2866
  finding2({
2530
- id: `${LENS_ID3}/dangling-ref:${file.path}:${ref}`,
2867
+ id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
2531
2868
  tier: "gap",
2532
2869
  claim: `${file.path} references ${ref} \u2014 no such file exists`,
2533
2870
  evidence: [file.path, `missing: ${ref}`],
@@ -2539,6 +2876,49 @@ var instructionTruthLens = {
2539
2876
  );
2540
2877
  }
2541
2878
  }
2879
+ const auditedPaths = new Set(files.map((f) => f.path));
2880
+ const stateDocs = await listStateDocuments(root, facts);
2881
+ let qualifiedRefsSkipped = 0;
2882
+ let unresolvableRefs = 0;
2883
+ let ledgerIds = null;
2884
+ const ledgerSources = [];
2885
+ if (stateDocs.length) {
2886
+ for (const artifact of facts.artifacts) {
2887
+ if (artifact.kind !== "decisions" || !artifact.exists) continue;
2888
+ const text = await readText(path.join(root, artifact.path));
2889
+ if (text === null) continue;
2890
+ const entries = parseDecisionEntries(text);
2891
+ if (!entries.length) continue;
2892
+ ledgerIds ??= /* @__PURE__ */ new Set();
2893
+ for (const entry of entries) ledgerIds.add(entry.num);
2894
+ ledgerSources.push(artifact.path);
2895
+ }
2896
+ }
2897
+ for (const doc of stateDocs) {
2898
+ if (!auditedPaths.has(doc.path)) await auditClaims(doc);
2899
+ const { refs, qualifiedSkipped } = extractDecisionRefs(doc.text);
2900
+ qualifiedRefsSkipped += qualifiedSkipped;
2901
+ if (!refs.size) continue;
2902
+ if (!ledgerIds) {
2903
+ unresolvableRefs += refs.size;
2904
+ continue;
2905
+ }
2906
+ for (const [num, asWritten] of refs) {
2907
+ if (ledgerIds.has(num)) continue;
2908
+ findings.push(
2909
+ finding2({
2910
+ id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
2911
+ tier: "gap",
2912
+ claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
2913
+ evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
2914
+ why: "A state doc is read as ground truth on return; a citation the decision record cannot back sends readers to a ruling that was never written.",
2915
+ action: "Fix the reference \u2014 or record the missing decision.",
2916
+ effort: "S",
2917
+ confidence: "medium"
2918
+ })
2919
+ );
2920
+ }
2921
+ }
2542
2922
  if (ctx.baseline) {
2543
2923
  findings.push(
2544
2924
  ...compareCommands(ctx.baseline.facts, facts),
@@ -2585,6 +2965,21 @@ var instructionTruthLens = {
2585
2965
  `${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
2586
2966
  );
2587
2967
  }
2968
+ if (stateDocs.length) {
2969
+ disclosures.push(
2970
+ `Checked ${stateDocs.length} state document(s) for command, path, and decision-reference claims (same skip classes as instruction files); decision ids resolved against ${ledgerSources.length ? ledgerSources.join(", ") : "nothing \u2014 no decisions file with D-NNN entries"}.`
2971
+ );
2972
+ }
2973
+ if (qualifiedRefsSkipped) {
2974
+ disclosures.push(
2975
+ `${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
2976
+ );
2977
+ }
2978
+ if (unresolvableRefs) {
2979
+ disclosures.push(
2980
+ `${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
2981
+ );
2982
+ }
2588
2983
  if (excluded.length) {
2589
2984
  const shown = excluded.slice(0, 5).join(", ");
2590
2985
  disclosures.push(
@@ -2600,7 +2995,7 @@ var instructionTruthLens = {
2600
2995
  `Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and package roots. Heuristics: workspace-filtered commands skipped (${totalFilteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); gitignored claims unverifiable; create-this and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; framework-pattern staleness not checked.`
2601
2996
  );
2602
2997
  return {
2603
- lens: LENS_ID3,
2998
+ lens: LENS_ID4,
2604
2999
  version: "1",
2605
3000
  title: "Instruction truth",
2606
3001
  kind: "truth",
@@ -2612,276 +3007,6 @@ var instructionTruthLens = {
2612
3007
  }
2613
3008
  };
2614
3009
 
2615
- // src/lenses/state-freshness.ts
2616
- init_config();
2617
- init_util();
2618
- var LENS_ID4 = "state-freshness";
2619
- var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
2620
- var KNOWN_FORMAT_VERSION = 1;
2621
- var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
2622
- var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
2623
- var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
2624
- var MS_PER_DAY = 864e5;
2625
- function parseDecisionsFormat(text) {
2626
- const m = MARKER_RE.exec(text);
2627
- if (!m) return null;
2628
- const problems = [];
2629
- const fields = [];
2630
- const version = Number(m[1]);
2631
- if (version !== KNOWN_FORMAT_VERSION) {
2632
- problems.push(
2633
- `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
2634
- );
2635
- }
2636
- const attrs = (m[2] ?? "").trim();
2637
- if (!attrs) return { fields, problems };
2638
- const declared = /^fields=(.*)$/.exec(attrs);
2639
- if (!declared) {
2640
- problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
2641
- return { fields, problems };
2642
- }
2643
- const seen = new Set(BUILT_IN_FIELDS);
2644
- for (const raw of declared[1].split(",")) {
2645
- const name = raw.trim();
2646
- if (!name) continue;
2647
- if (!FIELD_NAME_RE.test(name)) {
2648
- problems.push(
2649
- `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
2650
- );
2651
- continue;
2652
- }
2653
- const key = name.toLowerCase();
2654
- if (seen.has(key)) continue;
2655
- seen.add(key);
2656
- fields.push(name);
2657
- }
2658
- if (fields.length === 0 && problems.length === 0) {
2659
- problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
2660
- }
2661
- return { fields, problems };
2662
- }
2663
- function hasField(block, name) {
2664
- return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
2665
- }
2666
- function parseDecisionEntries(text) {
2667
- const headings = [...text.matchAll(/^## .*$/gm)];
2668
- const entries = [];
2669
- for (let i = 0; i < headings.length; i++) {
2670
- const h = headings[i];
2671
- const m = /^## (D-(\d+))\b/.exec(h[0]);
2672
- if (!m) continue;
2673
- const start = (h.index ?? 0) + h[0].length;
2674
- const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
2675
- entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
2676
- }
2677
- return entries;
2678
- }
2679
- function checkIdSequence(file, entries) {
2680
- const findings = [];
2681
- const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
2682
- const seen = /* @__PURE__ */ new Map();
2683
- const duplicated = /* @__PURE__ */ new Set();
2684
- let prev;
2685
- for (const entry of entries) {
2686
- if (seen.has(entry.num)) {
2687
- if (!duplicated.has(entry.num)) {
2688
- duplicated.add(entry.num);
2689
- findings.push({
2690
- id: `${LENS_ID4}/duplicate-id:${file}:${entry.id}`,
2691
- lens: LENS_ID4,
2692
- tier: "gap",
2693
- claim: `${file} carries more than one ${entry.id} entry`,
2694
- evidence: [`${file}: ${entry.id} appears twice`],
2695
- why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
2696
- action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
2697
- effort: "S",
2698
- confidence: "high"
2699
- });
2700
- }
2701
- } else {
2702
- seen.set(entry.num, entry);
2703
- if (prev && entry.num < prev.num) {
2704
- findings.push({
2705
- id: `${LENS_ID4}/id-order:${file}:${entry.id}`,
2706
- lens: LENS_ID4,
2707
- tier: "gap",
2708
- claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
2709
- evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
2710
- 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.",
2711
- action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
2712
- effort: "S",
2713
- confidence: "high"
2714
- });
2715
- }
2716
- prev = entry;
2717
- }
2718
- }
2719
- return findings;
2720
- }
2721
- function checkFormatFields(file, entries, today, declaredFields) {
2722
- const findings = [];
2723
- for (const entry of entries) {
2724
- for (const field of declaredFields) {
2725
- if (hasField(entry.block, field)) continue;
2726
- findings.push({
2727
- id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
2728
- lens: LENS_ID4,
2729
- tier: "gap",
2730
- claim: `${file} ${entry.id} has no ${field}: field`,
2731
- evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
2732
- why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
2733
- action: `Add a ${field}: line to ${entry.id}.`,
2734
- effort: "S",
2735
- confidence: "high"
2736
- });
2737
- }
2738
- if (!/Scope[\s*]*:/.test(entry.block)) {
2739
- findings.push({
2740
- id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
2741
- lens: LENS_ID4,
2742
- tier: "gap",
2743
- claim: `${file} ${entry.id} has no Scope: field`,
2744
- evidence: [`${file}: ${entry.id}`],
2745
- why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
2746
- action: "Add a Scope: line naming what the decision binds.",
2747
- effort: "S",
2748
- confidence: "high"
2749
- });
2750
- }
2751
- const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
2752
- if (revisit && revisit[1] < today) {
2753
- findings.push({
2754
- id: `${LENS_ID4}/revisit-due:${file}:${entry.id}`,
2755
- lens: LENS_ID4,
2756
- tier: "gap",
2757
- claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
2758
- evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
2759
- why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
2760
- action: "Re-evaluate the decision: supersede it or move the Revisit date.",
2761
- effort: "S",
2762
- confidence: "high"
2763
- });
2764
- }
2765
- }
2766
- return findings;
2767
- }
2768
- var stateFreshnessLens = {
2769
- id: LENS_ID4,
2770
- version: "1",
2771
- title: "State freshness",
2772
- kind: "truth",
2773
- async run(ctx) {
2774
- const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
2775
- const findings = [];
2776
- const disclosures = [...ctx.config?.problems ?? []];
2777
- const outOfScope = [];
2778
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2779
- const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
2780
- const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
2781
- if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
2782
- disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
2783
- }
2784
- const freshness = ctx.facts.freshness;
2785
- if (!freshness) {
2786
- disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
2787
- } else {
2788
- for (const u of freshness.unverifiable) {
2789
- disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
2790
- }
2791
- for (const a of stateArtifacts) {
2792
- const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
2793
- if (!fact || !freshness.repoLastCommit) continue;
2794
- if (fact.dirty) {
2795
- disclosures.push(
2796
- `${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
2797
- );
2798
- continue;
2799
- }
2800
- if (!fact.commitsSince) continue;
2801
- const gapDays = Math.floor(
2802
- (Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
2803
- );
2804
- if (gapDays <= budgets.staleAfterDays) continue;
2805
- const escalated = gapDays > budgets.staleAfterDays * 3;
2806
- findings.push({
2807
- id: `${LENS_ID4}/stale-state:${a.path}`,
2808
- lens: LENS_ID4,
2809
- tier: escalated ? "risk" : "gap",
2810
- claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
2811
- evidence: [
2812
- `${a.path} last commit: ${fact.lastCommit}`,
2813
- `repo last commit: ${freshness.repoLastCommit}`
2814
- ],
2815
- 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.`,
2816
- action: "Refresh the state doc (or record why it is still current).",
2817
- effort: "S",
2818
- confidence: "high"
2819
- });
2820
- }
2821
- }
2822
- for (const a of stateArtifacts) {
2823
- const text = await readText(path.join(ctx.root, a.path));
2824
- if (text === null) {
2825
- disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
2826
- outOfScope.push(a.path);
2827
- continue;
2828
- }
2829
- if (text.length > budgets.maxChars) {
2830
- findings.push({
2831
- id: `${LENS_ID4}/state-over-budget:${a.path}`,
2832
- lens: LENS_ID4,
2833
- tier: "gap",
2834
- claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
2835
- evidence: [`${a.path}: ${text.length} chars`],
2836
- 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.",
2837
- action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
2838
- effort: "M",
2839
- confidence: "high"
2840
- });
2841
- }
2842
- }
2843
- for (const a of decisionArtifacts) {
2844
- const text = await readText(path.join(ctx.root, a.path));
2845
- if (text === null) {
2846
- disclosures.push(
2847
- `${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
2848
- );
2849
- continue;
2850
- }
2851
- const entries = parseDecisionEntries(text);
2852
- findings.push(...checkIdSequence(a.path, entries));
2853
- const format = parseDecisionsFormat(text);
2854
- if (!format) {
2855
- disclosures.push(
2856
- `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
2857
- );
2858
- outOfScope.push(a.path);
2859
- continue;
2860
- }
2861
- for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
2862
- if (format.fields.length > 0) {
2863
- disclosures.push(
2864
- `${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry (etymd attaches no meaning to the names).`
2865
- );
2866
- }
2867
- findings.push(...checkFormatFields(a.path, entries, today, format.fields));
2868
- }
2869
- disclosures.push(
2870
- `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.`
2871
- );
2872
- return {
2873
- lens: LENS_ID4,
2874
- version: "1",
2875
- title: "State freshness",
2876
- kind: "truth",
2877
- status: "ran",
2878
- disclosures,
2879
- findings,
2880
- ...outOfScope.length ? { outOfScope } : {}
2881
- };
2882
- }
2883
- };
2884
-
2885
3010
  // src/engine/finding.ts
2886
3011
  var TIER_ORDER = { risk: 0, gap: 1, polish: 2 };
2887
3012
  var EFFORT_ORDER = { S: 0, M: 1, L: 2 };