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.
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { measureContext } from './chunk-OKU3HXIH.js';
2
+ import { measureContext } from './chunk-JSUYV26K.js';
3
3
  import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-OAFYLBVG.js';
4
- import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-K7QBTANO.js';
5
- import { scanProject, expandFileGlobs } from './chunk-XJMN2HMH.js';
4
+ import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-D2RI3CCT.js';
5
+ import { scanProject, expandFileGlobs } from './chunk-MUNRU2LF.js';
6
6
  import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-C7LBUFNU.js';
7
7
  import { PACK_VERSION } from './chunk-6DUDIVIC.js';
8
- import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath, isExecutable } from './chunk-IV3FYVTS.js';
8
+ import { pathExists, readJson, readText, isDirectory, matchesAnyGlob, git, normalizeRelPath, isCiEnvironment, isExecutable } from './chunk-IV3FYVTS.js';
9
9
  import path from 'node:path';
10
10
  import YAML from 'yaml';
11
11
  import { promises } from 'node:fs';
@@ -667,6 +667,281 @@ var gateIntegrityLens = {
667
667
  };
668
668
  }
669
669
  };
670
+ var LENS_ID3 = "state-freshness";
671
+ var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
672
+ var KNOWN_FORMAT_VERSION = 1;
673
+ var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
674
+ var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
675
+ var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
676
+ var MS_PER_DAY = 864e5;
677
+ function parseDecisionsFormat(text) {
678
+ const m = MARKER_RE.exec(text);
679
+ if (!m) return null;
680
+ const problems = [];
681
+ const fields = [];
682
+ const offset = m.index ?? 0;
683
+ const version = Number(m[1]);
684
+ if (version !== KNOWN_FORMAT_VERSION) {
685
+ problems.push(
686
+ `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
687
+ );
688
+ }
689
+ const attrs = (m[2] ?? "").trim();
690
+ if (!attrs) return { fields, offset, problems };
691
+ const declared = /^fields=(.*)$/.exec(attrs);
692
+ if (!declared) {
693
+ problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
694
+ return { fields, offset, problems };
695
+ }
696
+ const seen = new Set(BUILT_IN_FIELDS);
697
+ for (const raw of declared[1].split(",")) {
698
+ const name = raw.trim();
699
+ if (!name) continue;
700
+ if (!FIELD_NAME_RE.test(name)) {
701
+ problems.push(
702
+ `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
703
+ );
704
+ continue;
705
+ }
706
+ const key = name.toLowerCase();
707
+ if (seen.has(key)) continue;
708
+ seen.add(key);
709
+ fields.push(name);
710
+ }
711
+ if (fields.length === 0 && problems.length === 0) {
712
+ problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
713
+ }
714
+ return { fields, offset, problems };
715
+ }
716
+ function hasField(block, name) {
717
+ return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
718
+ }
719
+ function parseDecisionEntries(text) {
720
+ const headings = [...text.matchAll(/^## .*$/gm)];
721
+ const entries = [];
722
+ for (let i = 0; i < headings.length; i++) {
723
+ const h = headings[i];
724
+ const m = /^## (D-(\d+))\b/.exec(h[0]);
725
+ if (!m) continue;
726
+ const offset = h.index ?? 0;
727
+ const start = offset + h[0].length;
728
+ const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
729
+ entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
730
+ }
731
+ return entries;
732
+ }
733
+ function checkIdSequence(file, entries) {
734
+ const findings = [];
735
+ const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
736
+ const seen = /* @__PURE__ */ new Map();
737
+ const duplicated = /* @__PURE__ */ new Set();
738
+ let prev;
739
+ for (const entry of entries) {
740
+ if (seen.has(entry.num)) {
741
+ if (!duplicated.has(entry.num)) {
742
+ duplicated.add(entry.num);
743
+ findings.push({
744
+ id: `${LENS_ID3}/duplicate-id:${file}:${entry.id}`,
745
+ lens: LENS_ID3,
746
+ tier: "gap",
747
+ claim: `${file} carries more than one ${entry.id} entry`,
748
+ evidence: [`${file}: ${entry.id} appears twice`],
749
+ why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
750
+ action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
751
+ effort: "S",
752
+ confidence: "high"
753
+ });
754
+ }
755
+ } else {
756
+ seen.set(entry.num, entry);
757
+ if (prev && entry.num < prev.num) {
758
+ findings.push({
759
+ id: `${LENS_ID3}/id-order:${file}:${entry.id}`,
760
+ lens: LENS_ID3,
761
+ tier: "gap",
762
+ claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
763
+ evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
764
+ 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.",
765
+ action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
766
+ effort: "S",
767
+ confidence: "high"
768
+ });
769
+ }
770
+ prev = entry;
771
+ }
772
+ }
773
+ return findings;
774
+ }
775
+ function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
776
+ const findings = [];
777
+ for (const entry of entries) {
778
+ const bound = entry.offset >= markerOffset;
779
+ for (const field of bound ? declaredFields : []) {
780
+ if (hasField(entry.block, field)) continue;
781
+ findings.push({
782
+ id: `${LENS_ID3}/field-missing:${file}:${entry.id}:${field}`,
783
+ lens: LENS_ID3,
784
+ tier: "gap",
785
+ claim: `${file} ${entry.id} has no ${field}: field`,
786
+ evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
787
+ why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
788
+ action: `Add a ${field}: line to ${entry.id}.`,
789
+ effort: "S",
790
+ confidence: "high"
791
+ });
792
+ }
793
+ if (bound && !/Scope[\s*]*:/.test(entry.block)) {
794
+ findings.push({
795
+ id: `${LENS_ID3}/scope-missing:${file}:${entry.id}`,
796
+ lens: LENS_ID3,
797
+ tier: "gap",
798
+ claim: `${file} ${entry.id} has no Scope: field`,
799
+ evidence: [`${file}: ${entry.id}`],
800
+ why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
801
+ action: "Add a Scope: line naming what the decision binds.",
802
+ effort: "S",
803
+ confidence: "high"
804
+ });
805
+ }
806
+ const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
807
+ if (revisit && revisit[1] < today) {
808
+ findings.push({
809
+ id: `${LENS_ID3}/revisit-due:${file}:${entry.id}`,
810
+ lens: LENS_ID3,
811
+ tier: "gap",
812
+ claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
813
+ evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
814
+ why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
815
+ action: "Re-evaluate the decision: supersede it or move the Revisit date.",
816
+ effort: "S",
817
+ confidence: "high"
818
+ });
819
+ }
820
+ }
821
+ return findings;
822
+ }
823
+ var stateFreshnessLens = {
824
+ id: LENS_ID3,
825
+ version: "1",
826
+ title: "State freshness",
827
+ kind: "truth",
828
+ async run(ctx) {
829
+ const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
830
+ const findings = [];
831
+ const disclosures = [...ctx.config?.problems ?? []];
832
+ const outOfScope = [];
833
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
834
+ const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
835
+ const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
836
+ if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
837
+ disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
838
+ }
839
+ const freshness = ctx.facts.freshness;
840
+ if (!freshness) {
841
+ disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
842
+ } else {
843
+ for (const u of freshness.unverifiable) {
844
+ disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
845
+ }
846
+ for (const a of stateArtifacts) {
847
+ const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
848
+ if (!fact || !freshness.repoLastCommit) continue;
849
+ if (fact.dirty) {
850
+ disclosures.push(
851
+ `${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
852
+ );
853
+ continue;
854
+ }
855
+ if (!fact.commitsSince) continue;
856
+ const gapDays = Math.floor(
857
+ (Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
858
+ );
859
+ if (gapDays <= budgets.staleAfterDays) continue;
860
+ const escalated = gapDays > budgets.staleAfterDays * 3;
861
+ findings.push({
862
+ id: `${LENS_ID3}/stale-state:${a.path}`,
863
+ lens: LENS_ID3,
864
+ tier: escalated ? "risk" : "gap",
865
+ claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
866
+ evidence: [
867
+ `${a.path} last commit: ${fact.lastCommit}`,
868
+ `repo last commit: ${freshness.repoLastCommit}`
869
+ ],
870
+ 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.`,
871
+ action: "Refresh the state doc (or record why it is still current).",
872
+ effort: "S",
873
+ confidence: "high"
874
+ });
875
+ }
876
+ }
877
+ for (const a of stateArtifacts) {
878
+ const text = await readText(path.join(ctx.root, a.path));
879
+ if (text === null) {
880
+ disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
881
+ outOfScope.push(a.path);
882
+ continue;
883
+ }
884
+ if (text.length > budgets.maxChars) {
885
+ findings.push({
886
+ id: `${LENS_ID3}/state-over-budget:${a.path}`,
887
+ lens: LENS_ID3,
888
+ tier: "gap",
889
+ claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
890
+ evidence: [`${a.path}: ${text.length} chars`],
891
+ 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.",
892
+ action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
893
+ effort: "M",
894
+ confidence: "high"
895
+ });
896
+ }
897
+ }
898
+ for (const a of decisionArtifacts) {
899
+ const text = await readText(path.join(ctx.root, a.path));
900
+ if (text === null) {
901
+ disclosures.push(
902
+ `${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
903
+ );
904
+ continue;
905
+ }
906
+ const entries = parseDecisionEntries(text);
907
+ findings.push(...checkIdSequence(a.path, entries));
908
+ const format = parseDecisionsFormat(text);
909
+ if (!format) {
910
+ disclosures.push(
911
+ `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
912
+ );
913
+ outOfScope.push(a.path);
914
+ continue;
915
+ }
916
+ for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
917
+ const exempt = entries.filter((e) => e.offset < format.offset);
918
+ if (format.fields.length > 0) {
919
+ disclosures.push(
920
+ `${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).`
921
+ );
922
+ }
923
+ if (exempt.length > 0) {
924
+ disclosures.push(
925
+ `${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).`
926
+ );
927
+ }
928
+ findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
929
+ }
930
+ disclosures.push(
931
+ `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.`
932
+ );
933
+ return {
934
+ lens: LENS_ID3,
935
+ version: "1",
936
+ title: "State freshness",
937
+ kind: "truth",
938
+ status: "ran",
939
+ disclosures,
940
+ findings,
941
+ ...outOfScope.length ? { outOfScope } : {}
942
+ };
943
+ }
944
+ };
670
945
  async function listInstructionFiles(root, facts, scope) {
671
946
  const files = [];
672
947
  const add = async (rel) => {
@@ -724,6 +999,15 @@ async function listInstructionFiles(root, facts, scope) {
724
999
  }
725
1000
  return { files: kept, excluded, included };
726
1001
  }
1002
+ async function listStateDocuments(root, facts) {
1003
+ const docs = [];
1004
+ for (const artifact of facts.artifacts) {
1005
+ if (artifact.kind !== "state" || !artifact.exists) continue;
1006
+ const text = await readText(path.join(root, artifact.path));
1007
+ if (text !== null) docs.push({ path: normalizeRelPath(artifact.path), text });
1008
+ }
1009
+ return docs;
1010
+ }
727
1011
  function extractCodeTokens(text) {
728
1012
  const tokens = [];
729
1013
  for (const m of text.matchAll(/`([^`\n]+)`/g)) tokens.push(m[1].trim());
@@ -861,6 +1145,30 @@ function extractPathClaims(text) {
861
1145
  }
862
1146
  return { paths, prospective, placeholder: [...placeholder] };
863
1147
  }
1148
+ var LOCAL_REF_LEADINS = new Set(
1149
+ "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(" ")
1150
+ );
1151
+ function extractDecisionRefs(text) {
1152
+ const byNum = /* @__PURE__ */ new Map();
1153
+ for (const m of text.matchAll(/\bD-(\d{1,4})\b/g)) {
1154
+ const index = m.index ?? 0;
1155
+ if (index > 0 && /[-/_.]/.test(text[index - 1])) continue;
1156
+ const before = text.slice(Math.max(0, index - 48), index);
1157
+ const lead = /([A-Za-z][A-Za-z0-9'’-]*)[ \t]+$/.exec(before)?.[1];
1158
+ const local = !lead || LOCAL_REF_LEADINS.has(lead.toLowerCase());
1159
+ const num = Number(m[1]);
1160
+ const seen = byNum.get(num);
1161
+ if (!seen) byNum.set(num, { asWritten: m[0], local });
1162
+ else seen.local = seen.local || local;
1163
+ }
1164
+ const refs = /* @__PURE__ */ new Map();
1165
+ let qualifiedSkipped = 0;
1166
+ for (const [num, ref] of byNum) {
1167
+ if (ref.local) refs.set(num, ref.asWritten);
1168
+ else qualifiedSkipped += 1;
1169
+ }
1170
+ return { refs, qualifiedSkipped };
1171
+ }
864
1172
  function packageManagerUsage(text) {
865
1173
  const counts = /* @__PURE__ */ new Map();
866
1174
  for (const token of extractCodeTokens(text)) {
@@ -883,10 +1191,10 @@ function extractDocRefs(text) {
883
1191
  }
884
1192
 
885
1193
  // src/lenses/instruction-truth/lens.ts
886
- var LENS_ID3 = "instruction-truth";
1194
+ var LENS_ID4 = "instruction-truth";
887
1195
  var MAX_PATH_FINDINGS_PER_FILE = 15;
888
1196
  function finding2(partial) {
889
- return { lens: LENS_ID3, ...partial };
1197
+ return { lens: LENS_ID4, ...partial };
890
1198
  }
891
1199
  function compareCommands(baseline, fresh) {
892
1200
  const out = [];
@@ -895,7 +1203,7 @@ function compareCommands(baseline, fresh) {
895
1203
  if (before && !(before in fresh.commands.raw)) {
896
1204
  out.push(
897
1205
  finding2({
898
- id: `${LENS_ID3}/command-gone-${role}`,
1206
+ id: `${LENS_ID4}/command-gone-${role}`,
899
1207
  tier: "risk",
900
1208
  claim: `Documented ${role} command \`${before}\` no longer exists in package.json`,
901
1209
  evidence: ["package.json"],
@@ -917,7 +1225,7 @@ function compareArtifacts(baseline, fresh) {
917
1225
  if (a.exists && now && !now.exists) {
918
1226
  out.push(
919
1227
  finding2({
920
- id: `${LENS_ID3}/artifact-gone-${a.id}`,
1228
+ id: `${LENS_ID4}/artifact-gone-${a.id}`,
921
1229
  tier: "gap",
922
1230
  claim: `${a.label} was present at baseline but is now missing`,
923
1231
  evidence: [a.path],
@@ -935,7 +1243,7 @@ function compareLayout(baseline, fresh) {
935
1243
  const now = new Set(fresh.tree.dirs.map((d) => d.name));
936
1244
  return baseline.tree.dirs.filter((d) => !now.has(d.name)).map(
937
1245
  (d) => finding2({
938
- id: `${LENS_ID3}/dir-gone-${d.name}`,
1246
+ id: `${LENS_ID4}/dir-gone-${d.name}`,
939
1247
  tier: "gap",
940
1248
  claim: `Top-level \`${d.name}/\` from the baseline no longer exists \u2014 the repo map may be stale`,
941
1249
  evidence: [`${d.name}/`],
@@ -947,7 +1255,7 @@ function compareLayout(baseline, fresh) {
947
1255
  );
948
1256
  }
949
1257
  var instructionTruthLens = {
950
- id: LENS_ID3,
1258
+ id: LENS_ID4,
951
1259
  version: "1",
952
1260
  title: "Instruction truth",
953
1261
  kind: "truth",
@@ -965,7 +1273,7 @@ var instructionTruthLens = {
965
1273
  if (!files.length) {
966
1274
  findings.push(
967
1275
  finding2({
968
- id: `${LENS_ID3}/no-contract`,
1276
+ id: `${LENS_ID4}/no-contract`,
969
1277
  tier: "gap",
970
1278
  claim: "No agent instruction files exist (AGENTS.md or equivalents)",
971
1279
  evidence: ["AGENTS.md (missing)"],
@@ -1000,13 +1308,14 @@ var instructionTruthLens = {
1000
1308
  return false;
1001
1309
  };
1002
1310
  const nodeModulesInstalled = await pathExists(path.join(root, "node_modules"));
1311
+ const manifestExists = await pathExists(path.join(root, "package.json")) || facts.packages.length > 0;
1003
1312
  let totalFilteredSkipped = 0;
1004
1313
  let binaryResolved = 0;
1005
1314
  let unverifiableCommands = 0;
1006
1315
  let gitignoredSkipped = 0;
1007
1316
  let prospectiveSkipped = 0;
1008
1317
  let placeholderSkipped = 0;
1009
- for (const file of files) {
1318
+ const auditClaims = async (file) => {
1010
1319
  const { scripts: claimed, filteredSkipped } = extractCommandClaims(file.text);
1011
1320
  totalFilteredSkipped += filteredSkipped;
1012
1321
  for (const [script, raw] of claimed) {
@@ -1015,13 +1324,13 @@ var instructionTruthLens = {
1015
1324
  binaryResolved += 1;
1016
1325
  continue;
1017
1326
  }
1018
- if (!nodeModulesInstalled) {
1327
+ if (!nodeModulesInstalled && manifestExists) {
1019
1328
  unverifiableCommands += 1;
1020
1329
  continue;
1021
1330
  }
1022
1331
  findings.push(
1023
1332
  finding2({
1024
- id: `${LENS_ID3}/stale-command:${file.path}:${script}`,
1333
+ id: `${LENS_ID4}/stale-command:${file.path}:${script}`,
1025
1334
  tier: "risk",
1026
1335
  claim: `${file.path} tells agents to run \`${script}\` \u2014 no such script exists`,
1027
1336
  evidence: [`${file.path}: \`${raw}\``, "package.json scripts (root + workspaces)"],
@@ -1056,7 +1365,7 @@ var instructionTruthLens = {
1056
1365
  pathFindings += 1;
1057
1366
  findings.push(
1058
1367
  finding2({
1059
- id: `${LENS_ID3}/stale-path:${file.path}:${claim}`,
1368
+ id: `${LENS_ID4}/stale-path:${file.path}:${claim}`,
1060
1369
  tier: "gap",
1061
1370
  claim: `${file.path} references \`${claim}\` \u2014 it does not exist in the repo`,
1062
1371
  evidence: [file.path, `missing: ${claim}`],
@@ -1067,6 +1376,9 @@ var instructionTruthLens = {
1067
1376
  })
1068
1377
  );
1069
1378
  }
1379
+ };
1380
+ for (const file of files) {
1381
+ await auditClaims(file);
1070
1382
  if (facts.packageManager !== "unknown") {
1071
1383
  const usage = packageManagerUsage(file.text);
1072
1384
  const own = usage.get(facts.packageManager) ?? 0;
@@ -1074,7 +1386,7 @@ var instructionTruthLens = {
1074
1386
  if (pm === facts.packageManager || count < 2 || count <= own) continue;
1075
1387
  findings.push(
1076
1388
  finding2({
1077
- id: `${LENS_ID3}/pm-conflict:${file.path}`,
1389
+ id: `${LENS_ID4}/pm-conflict:${file.path}`,
1078
1390
  tier: "gap",
1079
1391
  claim: `${file.path} instructs \`${pm}\` (${count}\xD7) but the repo uses ${facts.packageManager}`,
1080
1392
  evidence: [file.path, `lockfile \u2192 ${facts.packageManager}`],
@@ -1091,7 +1403,7 @@ var instructionTruthLens = {
1091
1403
  if (await pathExists(path.join(root, ref))) continue;
1092
1404
  findings.push(
1093
1405
  finding2({
1094
- id: `${LENS_ID3}/dangling-ref:${file.path}:${ref}`,
1406
+ id: `${LENS_ID4}/dangling-ref:${file.path}:${ref}`,
1095
1407
  tier: "gap",
1096
1408
  claim: `${file.path} references ${ref} \u2014 no such file exists`,
1097
1409
  evidence: [file.path, `missing: ${ref}`],
@@ -1103,8 +1415,51 @@ var instructionTruthLens = {
1103
1415
  );
1104
1416
  }
1105
1417
  }
1106
- if (ctx.baseline) {
1107
- findings.push(
1418
+ const auditedPaths = new Set(files.map((f) => f.path));
1419
+ const stateDocs = await listStateDocuments(root, facts);
1420
+ let qualifiedRefsSkipped = 0;
1421
+ let unresolvableRefs = 0;
1422
+ let ledgerIds = null;
1423
+ const ledgerSources = [];
1424
+ if (stateDocs.length) {
1425
+ for (const artifact of facts.artifacts) {
1426
+ if (artifact.kind !== "decisions" || !artifact.exists) continue;
1427
+ const text = await readText(path.join(root, artifact.path));
1428
+ if (text === null) continue;
1429
+ const entries = parseDecisionEntries(text);
1430
+ if (!entries.length) continue;
1431
+ ledgerIds ??= /* @__PURE__ */ new Set();
1432
+ for (const entry of entries) ledgerIds.add(entry.num);
1433
+ ledgerSources.push(artifact.path);
1434
+ }
1435
+ }
1436
+ for (const doc of stateDocs) {
1437
+ if (!auditedPaths.has(doc.path)) await auditClaims(doc);
1438
+ const { refs, qualifiedSkipped } = extractDecisionRefs(doc.text);
1439
+ qualifiedRefsSkipped += qualifiedSkipped;
1440
+ if (!refs.size) continue;
1441
+ if (!ledgerIds) {
1442
+ unresolvableRefs += refs.size;
1443
+ continue;
1444
+ }
1445
+ for (const [num, asWritten] of refs) {
1446
+ if (ledgerIds.has(num)) continue;
1447
+ findings.push(
1448
+ finding2({
1449
+ id: `${LENS_ID4}/dead-decision-ref:${doc.path}:${asWritten}`,
1450
+ tier: "gap",
1451
+ claim: `${doc.path} cites ${asWritten} \u2014 no such entry exists in ${ledgerSources.join(", ")}`,
1452
+ evidence: [doc.path, `${ledgerSources.join(", ")}: no ${asWritten} entry`],
1453
+ 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.",
1454
+ action: "Fix the reference \u2014 or record the missing decision.",
1455
+ effort: "S",
1456
+ confidence: "medium"
1457
+ })
1458
+ );
1459
+ }
1460
+ }
1461
+ if (ctx.baseline) {
1462
+ findings.push(
1108
1463
  ...compareCommands(ctx.baseline.facts, facts),
1109
1464
  ...compareArtifacts(ctx.baseline.facts, facts),
1110
1465
  ...compareLayout(ctx.baseline.facts, facts)
@@ -1149,6 +1504,21 @@ var instructionTruthLens = {
1149
1504
  `${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
1150
1505
  );
1151
1506
  }
1507
+ if (stateDocs.length) {
1508
+ disclosures.push(
1509
+ `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"}.`
1510
+ );
1511
+ }
1512
+ if (qualifiedRefsSkipped) {
1513
+ disclosures.push(
1514
+ `${qualifiedRefsSkipped} decision reference(s) name another record (e.g. a fleet-level ledger) \u2014 not claims about this repo's decisions; skipped, not flagged.`
1515
+ );
1516
+ }
1517
+ if (unresolvableRefs) {
1518
+ disclosures.push(
1519
+ `${unresolvableRefs} decision reference(s) could not be resolved \u2014 no decisions file with \`## D-NNN\` entries; skipped, not flagged.`
1520
+ );
1521
+ }
1152
1522
  if (excluded.length) {
1153
1523
  const shown = excluded.slice(0, 5).join(", ");
1154
1524
  disclosures.push(
@@ -1164,7 +1534,7 @@ var instructionTruthLens = {
1164
1534
  `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.`
1165
1535
  );
1166
1536
  return {
1167
- lens: LENS_ID3,
1537
+ lens: LENS_ID4,
1168
1538
  version: "1",
1169
1539
  title: "Instruction truth",
1170
1540
  kind: "truth",
@@ -1175,272 +1545,6 @@ var instructionTruthLens = {
1175
1545
  };
1176
1546
  }
1177
1547
  };
1178
- var LENS_ID4 = "state-freshness";
1179
- var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
1180
- var KNOWN_FORMAT_VERSION = 1;
1181
- var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
1182
- var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
1183
- var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
1184
- var MS_PER_DAY = 864e5;
1185
- function parseDecisionsFormat(text) {
1186
- const m = MARKER_RE.exec(text);
1187
- if (!m) return null;
1188
- const problems = [];
1189
- const fields = [];
1190
- const version = Number(m[1]);
1191
- if (version !== KNOWN_FORMAT_VERSION) {
1192
- problems.push(
1193
- `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
1194
- );
1195
- }
1196
- const attrs = (m[2] ?? "").trim();
1197
- if (!attrs) return { fields, problems };
1198
- const declared = /^fields=(.*)$/.exec(attrs);
1199
- if (!declared) {
1200
- problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
1201
- return { fields, problems };
1202
- }
1203
- const seen = new Set(BUILT_IN_FIELDS);
1204
- for (const raw of declared[1].split(",")) {
1205
- const name = raw.trim();
1206
- if (!name) continue;
1207
- if (!FIELD_NAME_RE.test(name)) {
1208
- problems.push(
1209
- `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
1210
- );
1211
- continue;
1212
- }
1213
- const key = name.toLowerCase();
1214
- if (seen.has(key)) continue;
1215
- seen.add(key);
1216
- fields.push(name);
1217
- }
1218
- if (fields.length === 0 && problems.length === 0) {
1219
- problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
1220
- }
1221
- return { fields, problems };
1222
- }
1223
- function hasField(block, name) {
1224
- return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
1225
- }
1226
- function parseDecisionEntries(text) {
1227
- const headings = [...text.matchAll(/^## .*$/gm)];
1228
- const entries = [];
1229
- for (let i = 0; i < headings.length; i++) {
1230
- const h = headings[i];
1231
- const m = /^## (D-(\d+))\b/.exec(h[0]);
1232
- if (!m) continue;
1233
- const start = (h.index ?? 0) + h[0].length;
1234
- const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
1235
- entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
1236
- }
1237
- return entries;
1238
- }
1239
- function checkIdSequence(file, entries) {
1240
- const findings = [];
1241
- const nextFree = Math.max(0, ...entries.map((e) => e.num)) + 1;
1242
- const seen = /* @__PURE__ */ new Map();
1243
- const duplicated = /* @__PURE__ */ new Set();
1244
- let prev;
1245
- for (const entry of entries) {
1246
- if (seen.has(entry.num)) {
1247
- if (!duplicated.has(entry.num)) {
1248
- duplicated.add(entry.num);
1249
- findings.push({
1250
- id: `${LENS_ID4}/duplicate-id:${file}:${entry.id}`,
1251
- lens: LENS_ID4,
1252
- tier: "gap",
1253
- claim: `${file} carries more than one ${entry.id} entry`,
1254
- evidence: [`${file}: ${entry.id} appears twice`],
1255
- why: "Two decisions under one id cannot be cited, superseded, or dismissed unambiguously \u2014 append races collide exactly here.",
1256
- action: `Rename the later entry to D-${String(nextFree).padStart(3, "0")} (the next free id).`,
1257
- effort: "S",
1258
- confidence: "high"
1259
- });
1260
- }
1261
- } else {
1262
- seen.set(entry.num, entry);
1263
- if (prev && entry.num < prev.num) {
1264
- findings.push({
1265
- id: `${LENS_ID4}/id-order:${file}:${entry.id}`,
1266
- lens: LENS_ID4,
1267
- tier: "gap",
1268
- claim: `${file} lists ${entry.id} after ${prev.id} \u2014 ids out of append order`,
1269
- evidence: [`${file}: ${prev.id} precedes ${entry.id}`],
1270
- 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.",
1271
- action: `Rename the out-of-order entry into sequence (next free id: D-${String(nextFree).padStart(3, "0")}).`,
1272
- effort: "S",
1273
- confidence: "high"
1274
- });
1275
- }
1276
- prev = entry;
1277
- }
1278
- }
1279
- return findings;
1280
- }
1281
- function checkFormatFields(file, entries, today, declaredFields) {
1282
- const findings = [];
1283
- for (const entry of entries) {
1284
- for (const field of declaredFields) {
1285
- if (hasField(entry.block, field)) continue;
1286
- findings.push({
1287
- id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
1288
- lens: LENS_ID4,
1289
- tier: "gap",
1290
- claim: `${file} ${entry.id} has no ${field}: field`,
1291
- evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
1292
- why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
1293
- action: `Add a ${field}: line to ${entry.id}.`,
1294
- effort: "S",
1295
- confidence: "high"
1296
- });
1297
- }
1298
- if (!/Scope[\s*]*:/.test(entry.block)) {
1299
- findings.push({
1300
- id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
1301
- lens: LENS_ID4,
1302
- tier: "gap",
1303
- claim: `${file} ${entry.id} has no Scope: field`,
1304
- evidence: [`${file}: ${entry.id}`],
1305
- why: "A decision without a scope binds nobody \u2014 a reader cannot tell whether it covers the project they are working in.",
1306
- action: "Add a Scope: line naming what the decision binds.",
1307
- effort: "S",
1308
- confidence: "high"
1309
- });
1310
- }
1311
- const revisit = /Revisit[\s*]*:[\s*]*(\d{4}-\d{2}-\d{2})/.exec(entry.block);
1312
- if (revisit && revisit[1] < today) {
1313
- findings.push({
1314
- id: `${LENS_ID4}/revisit-due:${file}:${entry.id}`,
1315
- lens: LENS_ID4,
1316
- tier: "gap",
1317
- claim: `${file} ${entry.id} was due for revisit on ${revisit[1]}`,
1318
- evidence: [`${file}: ${entry.id} Revisit: ${revisit[1]}`],
1319
- why: "Review debt is due \u2014 a Revisit date is a promise to re-evaluate, and a past one silently hardens into policy.",
1320
- action: "Re-evaluate the decision: supersede it or move the Revisit date.",
1321
- effort: "S",
1322
- confidence: "high"
1323
- });
1324
- }
1325
- }
1326
- return findings;
1327
- }
1328
- var stateFreshnessLens = {
1329
- id: LENS_ID4,
1330
- version: "1",
1331
- title: "State freshness",
1332
- kind: "truth",
1333
- async run(ctx) {
1334
- const budgets = ctx.config?.config.state ?? DEFAULT_CONFIG.state;
1335
- const findings = [];
1336
- const disclosures = [...ctx.config?.problems ?? []];
1337
- const outOfScope = [];
1338
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1339
- const stateArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "state" && a.exists);
1340
- const decisionArtifacts = ctx.facts.artifacts.filter((a) => a.kind === "decisions" && a.exists);
1341
- if (stateArtifacts.length === 0 && decisionArtifacts.length === 0) {
1342
- disclosures.push("No state or decisions artifacts detected \u2014 nothing to check.");
1343
- }
1344
- const freshness = ctx.facts.freshness;
1345
- if (!freshness) {
1346
- disclosures.push("Scan carried no freshness facts \u2014 staleness unchecked.");
1347
- } else {
1348
- for (const u of freshness.unverifiable) {
1349
- disclosures.push(`Freshness of ${u.path} is unverifiable (${u.reason}) \u2014 not flagged.`);
1350
- }
1351
- for (const a of stateArtifacts) {
1352
- const fact = freshness.artifacts.find((f) => f.artifactId === a.id);
1353
- if (!fact || !freshness.repoLastCommit) continue;
1354
- if (fact.dirty) {
1355
- disclosures.push(
1356
- `${a.path} has uncommitted changes \u2014 modified since its last commit; treated fresh-now, not flagged.`
1357
- );
1358
- continue;
1359
- }
1360
- if (!fact.commitsSince) continue;
1361
- const gapDays = Math.floor(
1362
- (Date.parse(freshness.repoLastCommit) - Date.parse(fact.lastCommit)) / MS_PER_DAY
1363
- );
1364
- if (gapDays <= budgets.staleAfterDays) continue;
1365
- const escalated = gapDays > budgets.staleAfterDays * 3;
1366
- findings.push({
1367
- id: `${LENS_ID4}/stale-state:${a.path}`,
1368
- lens: LENS_ID4,
1369
- tier: escalated ? "risk" : "gap",
1370
- claim: `${a.path} trails the repo by ${gapDays} days of commit traffic \u2014 it says "now" but the repo moved on`,
1371
- evidence: [
1372
- `${a.path} last commit: ${fact.lastCommit}`,
1373
- `repo last commit: ${freshness.repoLastCommit}`
1374
- ],
1375
- 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.`,
1376
- action: "Refresh the state doc (or record why it is still current).",
1377
- effort: "S",
1378
- confidence: "high"
1379
- });
1380
- }
1381
- }
1382
- for (const a of stateArtifacts) {
1383
- const text = await readText(path.join(ctx.root, a.path));
1384
- if (text === null) {
1385
- disclosures.push(`${a.path} could not be read \u2014 unexamined, not clean.`);
1386
- outOfScope.push(a.path);
1387
- continue;
1388
- }
1389
- if (text.length > budgets.maxChars) {
1390
- findings.push({
1391
- id: `${LENS_ID4}/state-over-budget:${a.path}`,
1392
- lens: LENS_ID4,
1393
- tier: "gap",
1394
- claim: `${a.path} is ${text.length} chars \u2014 over the ${budgets.maxChars}-char state budget`,
1395
- evidence: [`${a.path}: ${text.length} chars`],
1396
- 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.",
1397
- action: "Trim to budget; move overflow into decisions/docs and keep a pointer.",
1398
- effort: "M",
1399
- confidence: "high"
1400
- });
1401
- }
1402
- }
1403
- for (const a of decisionArtifacts) {
1404
- const text = await readText(path.join(ctx.root, a.path));
1405
- if (text === null) {
1406
- disclosures.push(
1407
- `${a.path} recognized as a decisions convention (directory) \u2014 age-exempt; per-entry format checks apply only to marker-carrying decisions files.`
1408
- );
1409
- continue;
1410
- }
1411
- const entries = parseDecisionEntries(text);
1412
- findings.push(...checkIdSequence(a.path, entries));
1413
- const format = parseDecisionsFormat(text);
1414
- if (!format) {
1415
- disclosures.push(
1416
- `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
1417
- );
1418
- outOfScope.push(a.path);
1419
- continue;
1420
- }
1421
- for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
1422
- if (format.fields.length > 0) {
1423
- disclosures.push(
1424
- `${a.path} declares required entry fields: ${format.fields.join(", ")} \u2014 checked on every entry (etymd attaches no meaning to the names).`
1425
- );
1426
- }
1427
- findings.push(...checkFormatFields(a.path, entries, today, format.fields));
1428
- }
1429
- disclosures.push(
1430
- `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.`
1431
- );
1432
- return {
1433
- lens: LENS_ID4,
1434
- version: "1",
1435
- title: "State freshness",
1436
- kind: "truth",
1437
- status: "ran",
1438
- disclosures,
1439
- findings,
1440
- ...outOfScope.length ? { outOfScope } : {}
1441
- };
1442
- }
1443
- };
1444
1548
 
1445
1549
  // src/engine/run.ts
1446
1550
  var LENSES = [