etymd 0.6.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # etymd
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f2c1bfd: `etymd gates` no longer writes a gate that cannot fail. It derives whether any risk-tier finding is
8
+ reachable in the repo (a package manifest to contradict, a state document to fall behind); where
9
+ none is, the generated hook drops to `--fail-on gap` and the output says so and why. A `failOn`
10
+ recorded in `.etymd/config.json` is never adjusted, and `gates` now states which tier it wrote and
11
+ where that tier came from — the config key was previously unmentioned anywhere in its output.
12
+ - 743ad44: Declared-field checks are forward-only from the marker's position, not from the file. A decisions
13
+ file that declares required entry fields mid-life no longer demands them from the entries appended
14
+ above the marker — an append-only record cannot backfill them. The built-in `Scope:` check shares
15
+ the same gate; `Revisit:` keeps whole-file reach, since it fires only where the entry already wrote
16
+ a date down. Entries exempt by position are counted and named in the lens disclosures.
17
+
18
+ ## 0.7.0
19
+
20
+ ### Minor Changes
21
+
22
+ - 7371c8b: Decisions records can require fields of their own, and etymd holds no opinion about which.
23
+
24
+ A decisions file already opts into per-entry format checks with a marker. It can now append field
25
+ names to it — `<!-- decisions-format: 1 fields=Owner,Rollback -->` — and every entry after the
26
+ marker must carry each one. `Owner:` and `**Owner:**` both count. Decision record:
27
+ [`docs/decisions/007-declared-entry-fields.md`](docs/decisions/007-declared-entry-fields.md).
28
+
29
+ **The tool ships the shape, not the vocabulary.** Etymd never interprets a declared name and has no
30
+ list of fields it thinks a record ought to have; it verifies that a line introducing the name
31
+ exists, and that is all. Which fields are worth requiring is the file's position to take, not the
32
+ tool's — a name coined for one project's process is exactly the kind of opinion this package does
33
+ not ship, however cleanly it would mechanize.
34
+
35
+ **Nothing is dropped in silence.** A name that cannot be used as a field, a marker version this
36
+ build does not know, and a redeclared `Scope` are each disclosed in the lens report. A file that
37
+ believes it declared a requirement, and is quietly audited without it, would come back clean for
38
+ the one reason this tool exists to reject — so the failure is stated rather than absorbed.
39
+ Declarable names are letters, digits, spaces, `-` and `_`, which keeps regex metacharacters out of
40
+ the matcher by construction.
41
+
42
+ **Enforcement is every-entry, with no keyword trigger.** Requiring the fields only on entries whose
43
+ prose looks like it is claiming something would flag an entry that mentions a closed tab or a fixed
44
+ price, and a false "your file is lying" costs more trust than a missed one. Every entry after the
45
+ marker is held to the same rule, which is what `Scope:` already does and takes one sentence to
46
+ explain.
47
+
48
+ **Existing files are unaffected.** `fields=` is an extension rather than a new format version: a
49
+ marker without it behaves exactly as before, pre-marker entries stay untouched, and a repo that
50
+ declares nothing sees no change. The declaration lives on the marker rather than in
51
+ `.etymd/config.json` so the requirement sits beside the entries it governs, which also lets a repo
52
+ keep two decision records with different obligations.
53
+
3
54
  ## 0.6.0
4
55
 
5
56
  ### Minor Changes
package/README.md CHANGED
@@ -147,7 +147,11 @@ _relative_ — a state doc is stale only when the repo moved past it, so a dorma
147
147
  state is current; a tracked file with uncommitted edits is treated fresh-now (the refresh is
148
148
  already on disk) and disclosed. Decisions records get format checks (`Scope:` presence, a
149
149
  `Revisit:` date that, once past, becomes a finding) — opt in by adding the literal marker
150
- `<!-- decisions-format: 1 -->` anywhere in the file; forward-only, never retroactive. Duplicate
150
+ `<!-- decisions-format: 1 -->` anywhere in the file; forward-only, never retroactive. A file can
151
+ require field names of its own by appending them to the marker —
152
+ `<!-- decisions-format: 1 fields=Owner,Rollback -->` — and each is then checked on every entry.
153
+ Etymd ships no field vocabulary and reads no meaning into the names; it verifies only that what
154
+ the file declared is present, and discloses any name it could not use. Duplicate
151
155
  or out-of-order `D-NNN` ids are flagged with a rename action even without the marker — an
152
156
  append race is a defect in the file's own convention, not a format opinion.
153
157
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { section, theme, print, renderBaselineDrift, glyph } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-ULEWJ3ZK.js';
4
- import { VERSION } from './chunk-HLWCODYX.js';
3
+ import { scanProject } from './chunk-CIRNG736.js';
4
+ import { VERSION } from './chunk-72Y2V7VF.js';
5
5
  import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-C7LBUFNU.js';
6
6
  import { PACK_VERSION } from './chunk-6DUDIVIC.js';
7
7
  import './chunk-IV3FYVTS.js';
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-KBYEWHYX.js';
3
+ import './chunk-PRIH2NZF.js';
4
+ import './chunk-JSUYV26K.js';
5
+ import './chunk-OAFYLBVG.js';
6
+ import './chunk-D2RI3CCT.js';
7
+ import './chunk-5BVKFJWM.js';
8
+ import './chunk-CIRNG736.js';
9
+ import './chunk-72Y2V7VF.js';
10
+ import './chunk-C7LBUFNU.js';
11
+ import './chunk-6DUDIVIC.js';
12
+ import './chunk-IV3FYVTS.js';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, theme } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-ULEWJ3ZK.js';
4
- import './chunk-HLWCODYX.js';
3
+ import { scanProject } from './chunk-CIRNG736.js';
4
+ import './chunk-72Y2V7VF.js';
5
5
  import { ETYMD_DIR } from './chunk-C7LBUFNU.js';
6
6
  import './chunk-6DUDIVIC.js';
7
7
  import './chunk-IV3FYVTS.js';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.6.0"};
4
+ version: "0.8.0"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-HLWCODYX.js';
2
+ import { VERSION } from './chunk-72Y2V7VF.js';
3
3
  import { PACK_VERSION } from './chunk-6DUDIVIC.js';
4
4
  import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-IV3FYVTS.js';
5
5
  import path from 'node:path';
@@ -33,7 +33,7 @@ async function readConfig(root) {
33
33
  const target = configPath(root);
34
34
  const problems = [];
35
35
  if (!await pathExists(target)) {
36
- return { config: DEFAULT_CONFIG, present: false, problems };
36
+ return { config: DEFAULT_CONFIG, present: false, problems, explicit: { gatesFailOn: false } };
37
37
  }
38
38
  const raw = await readText(target);
39
39
  let parsed;
@@ -43,11 +43,11 @@ async function readConfig(root) {
43
43
  problems.push(
44
44
  `${CONFIG_FILE} exists but is not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 defaults used.`
45
45
  );
46
- return { config: DEFAULT_CONFIG, present: true, problems };
46
+ return { config: DEFAULT_CONFIG, present: true, problems, explicit: { gatesFailOn: false } };
47
47
  }
48
48
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
49
49
  problems.push(`${CONFIG_FILE} must contain a JSON object \u2014 defaults used.`);
50
- return { config: DEFAULT_CONFIG, present: true, problems };
50
+ return { config: DEFAULT_CONFIG, present: true, problems, explicit: { gatesFailOn: false } };
51
51
  }
52
52
  const obj = parsed;
53
53
  const instructions = obj.instructions ?? {};
@@ -73,6 +73,7 @@ async function readConfig(root) {
73
73
  return {
74
74
  present: true,
75
75
  problems,
76
+ explicit: { gatesFailOn: failOn !== void 0 },
76
77
  config: {
77
78
  instructions: {
78
79
  include: readGlobList(instructions.include, "instructions.include", problems) ?? DEFAULT_CONFIG.instructions.include,
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG } from './chunk-K7QBTANO.js';
2
+ import { DEFAULT_CONFIG } from './chunk-D2RI3CCT.js';
3
3
  import { readText, wordCount, approxTokens, pathExists } from './chunk-IV3FYVTS.js';
4
4
  import { promises } from 'node:fs';
5
5
  import path from 'node:path';
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-OESQNO2J.js';
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-PRIH2NZF.js';
3
3
  import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-5BVKFJWM.js';
4
4
 
5
5
  // src/commands/audit.ts
@@ -1,8 +1,8 @@
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-ULEWJ3ZK.js';
4
+ import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-D2RI3CCT.js';
5
+ import { scanProject, expandFileGlobs } from './chunk-CIRNG736.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
8
  import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath, isExecutable } from './chunk-IV3FYVTS.js';
@@ -1177,7 +1177,53 @@ var instructionTruthLens = {
1177
1177
  };
1178
1178
  var LENS_ID4 = "state-freshness";
1179
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"]);
1180
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 offset = m.index ?? 0;
1191
+ const version = Number(m[1]);
1192
+ if (version !== KNOWN_FORMAT_VERSION) {
1193
+ problems.push(
1194
+ `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
1195
+ );
1196
+ }
1197
+ const attrs = (m[2] ?? "").trim();
1198
+ if (!attrs) return { fields, offset, problems };
1199
+ const declared = /^fields=(.*)$/.exec(attrs);
1200
+ if (!declared) {
1201
+ problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
1202
+ return { fields, offset, problems };
1203
+ }
1204
+ const seen = new Set(BUILT_IN_FIELDS);
1205
+ for (const raw of declared[1].split(",")) {
1206
+ const name = raw.trim();
1207
+ if (!name) continue;
1208
+ if (!FIELD_NAME_RE.test(name)) {
1209
+ problems.push(
1210
+ `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
1211
+ );
1212
+ continue;
1213
+ }
1214
+ const key = name.toLowerCase();
1215
+ if (seen.has(key)) continue;
1216
+ seen.add(key);
1217
+ fields.push(name);
1218
+ }
1219
+ if (fields.length === 0 && problems.length === 0) {
1220
+ problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
1221
+ }
1222
+ return { fields, offset, problems };
1223
+ }
1224
+ function hasField(block, name) {
1225
+ return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
1226
+ }
1181
1227
  function parseDecisionEntries(text) {
1182
1228
  const headings = [...text.matchAll(/^## .*$/gm)];
1183
1229
  const entries = [];
@@ -1185,9 +1231,10 @@ function parseDecisionEntries(text) {
1185
1231
  const h = headings[i];
1186
1232
  const m = /^## (D-(\d+))\b/.exec(h[0]);
1187
1233
  if (!m) continue;
1188
- const start = (h.index ?? 0) + h[0].length;
1234
+ const offset = h.index ?? 0;
1235
+ const start = offset + h[0].length;
1189
1236
  const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
1190
- entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
1237
+ entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
1191
1238
  }
1192
1239
  return entries;
1193
1240
  }
@@ -1233,10 +1280,25 @@ function checkIdSequence(file, entries) {
1233
1280
  }
1234
1281
  return findings;
1235
1282
  }
1236
- function checkFormatFields(file, entries, today) {
1283
+ function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
1237
1284
  const findings = [];
1238
1285
  for (const entry of entries) {
1239
- if (!/Scope[\s*]*:/.test(entry.block)) {
1286
+ const bound = entry.offset >= markerOffset;
1287
+ for (const field of bound ? declaredFields : []) {
1288
+ if (hasField(entry.block, field)) continue;
1289
+ findings.push({
1290
+ id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
1291
+ lens: LENS_ID4,
1292
+ tier: "gap",
1293
+ claim: `${file} ${entry.id} has no ${field}: field`,
1294
+ evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
1295
+ why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
1296
+ action: `Add a ${field}: line to ${entry.id}.`,
1297
+ effort: "S",
1298
+ confidence: "high"
1299
+ });
1300
+ }
1301
+ if (bound && !/Scope[\s*]*:/.test(entry.block)) {
1240
1302
  findings.push({
1241
1303
  id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
1242
1304
  lens: LENS_ID4,
@@ -1351,14 +1413,27 @@ var stateFreshnessLens = {
1351
1413
  }
1352
1414
  const entries = parseDecisionEntries(text);
1353
1415
  findings.push(...checkIdSequence(a.path, entries));
1354
- if (!text.includes(DECISIONS_FORMAT_MARKER)) {
1416
+ const format = parseDecisionsFormat(text);
1417
+ if (!format) {
1355
1418
  disclosures.push(
1356
1419
  `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
1357
1420
  );
1358
1421
  outOfScope.push(a.path);
1359
1422
  continue;
1360
1423
  }
1361
- findings.push(...checkFormatFields(a.path, entries, today));
1424
+ for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
1425
+ const exempt = entries.filter((e) => e.offset < format.offset);
1426
+ if (format.fields.length > 0) {
1427
+ disclosures.push(
1428
+ `${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).`
1429
+ );
1430
+ }
1431
+ if (exempt.length > 0) {
1432
+ disclosures.push(
1433
+ `${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).`
1434
+ );
1435
+ }
1436
+ findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
1362
1437
  }
1363
1438
  disclosures.push(
1364
1439
  `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.`
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG } from './chunk-K7QBTANO.js';
2
+ import { DEFAULT_CONFIG } from './chunk-D2RI3CCT.js';
3
3
  import { PACK_VERSION } from './chunk-6DUDIVIC.js';
4
4
  import { readText, pathExists } from './chunk-IV3FYVTS.js';
5
5
  import path from 'node:path';
@@ -292,6 +292,24 @@ function derivedCommands(facts, existingHook) {
292
292
  }
293
293
  return base;
294
294
  }
295
+ function riskReachability(facts) {
296
+ const reasons = [];
297
+ if (facts.publishRoute !== "none") {
298
+ reasons.push("an instruction file can name a package script that no longer exists");
299
+ }
300
+ if (facts.artifacts.some((a) => a.kind === "state" && a.exists)) {
301
+ reasons.push("a state doc can fall far enough behind the repo to escalate");
302
+ }
303
+ return reasons;
304
+ }
305
+ function deriveFailOn(facts, recorded) {
306
+ const reachable = riskReachability(facts);
307
+ if (recorded.explicit) return { failOn: recorded.failOn, source: "config", reachable };
308
+ if (recorded.failOn === "risk" && reachable.length === 0) {
309
+ return { failOn: "gap", source: "derived", reachable };
310
+ }
311
+ return { failOn: recorded.failOn, source: "default", reachable };
312
+ }
295
313
  async function planWorkflow(root, facts, opts) {
296
314
  const out = [];
297
315
  const add = async (rel, contents, label, executable = false) => {
@@ -346,4 +364,4 @@ async function planWorkflow(root, facts, opts) {
346
364
  return out;
347
365
  }
348
366
 
349
- export { derivedCommands, isSafeGateCommand, planWorkflow, runPrefix };
367
+ export { deriveFailOn, derivedCommands, isSafeGateCommand, planWorkflow, riskReachability, runPrefix };
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-HLWCODYX.js';
2
+ import { VERSION } from './chunk-72Y2V7VF.js';
3
3
  import path from 'node:path';
4
4
  import { Command } from 'commander';
5
5
  import pc from 'picocolors';
@@ -31,7 +31,7 @@ program.command("audit").description("Verify every instruction claim against the
31
31
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
32
32
  ).action(
33
33
  (opts, cmd) => action(async () => {
34
- const { run } = await import('./audit-ICT2JK7A.js');
34
+ const { run } = await import('./audit-HO56CZNV.js');
35
35
  await run({
36
36
  cwd: resolveCwd(cmd),
37
37
  json: opts.json,
@@ -44,7 +44,7 @@ program.command("audit").description("Verify every instruction claim against the
44
44
  );
45
45
  program.command("init").description("Onboard the truth guard: approve the baseline; scaffold AGENTS.md only if missing").option("-y, --yes", "accept defaults without prompting (never overwrites)").action(
46
46
  (opts, cmd) => action(async () => {
47
- const { run } = await import('./init-IPUPP4GB.js');
47
+ const { run } = await import('./init-SSAJVX52.js');
48
48
  await run({ cwd: resolveCwd(cmd), yes: opts.yes });
49
49
  })
50
50
  );
@@ -52,31 +52,31 @@ program.command("approve").description(
52
52
  "Re-approve the committed baseline after intentional structural changes (non-interactive)"
53
53
  ).action(
54
54
  (_opts, cmd) => action(async () => {
55
- const { run } = await import('./approve-ZLOW4A7R.js');
55
+ const { run } = await import('./approve-MYOIDCAM.js');
56
56
  await run({ cwd: resolveCwd(cmd) });
57
57
  })
58
58
  );
59
59
  program.command("scan").description("Deterministically reckon the project into a facts index").option("--json", "print the raw facts as JSON").option("--no-save", "do not write the .etymd cache").action(
60
60
  (opts, cmd) => action(async () => {
61
- const { run } = await import('./scan-6L4L2GXT.js');
61
+ const { run } = await import('./scan-H2IPH4C3.js');
62
62
  await run({ cwd: resolveCwd(cmd), json: opts.json, save: opts.save });
63
63
  })
64
64
  );
65
65
  program.command("doctor").description('Alias for `audit --truth` \u2014 "are the recorded instructions still true?"').option("--json", "print findings as JSON").action(
66
66
  (opts, cmd) => action(async () => {
67
- const { run } = await import('./doctor-PORXFSYT.js');
67
+ const { run } = await import('./doctor-ZAAEVVB4.js');
68
68
  await run({ cwd: resolveCwd(cmd), json: opts.json });
69
69
  })
70
70
  );
71
71
  program.command("context").description("Measure the always-loaded context footprint and flag extraction candidates").option("--json", "print the budget as JSON").action(
72
72
  (opts, cmd) => action(async () => {
73
- const { run } = await import('./context-ZEUI5ESZ.js');
73
+ const { run } = await import('./context-5ANOP2IJ.js');
74
74
  await run({ cwd: resolveCwd(cmd), json: opts.json });
75
75
  })
76
76
  );
77
77
  program.command("brief").description("Emit a grounded briefing for the in-repo agent to complete the semantic layer").option("--human", "write a human onboarding brief instead of the agent briefing").action(
78
78
  (opts, cmd) => action(async () => {
79
- const { run } = await import('./brief-PDUGGMLI.js');
79
+ const { run } = await import('./brief-XI4RCNYS.js');
80
80
  await run({ cwd: resolveCwd(cmd), human: opts.human });
81
81
  })
82
82
  );
@@ -111,7 +111,7 @@ var fleet = program.command("fleet").description(
111
111
  "exit non-zero when findings at/above this tier exist (risk|gap|polish)"
112
112
  ).action(
113
113
  (opts, cmd) => action(async () => {
114
- const { sweep } = await import('./fleet-JHNQOLSY.js');
114
+ const { sweep } = await import('./fleet-6U6XW72I.js');
115
115
  await sweep({
116
116
  cwd: resolveCwd(cmd),
117
117
  manifest: opts.manifest,
@@ -126,7 +126,7 @@ var fleet = program.command("fleet").description(
126
126
  );
127
127
  fleet.command("check").description("Validate the manifest pair only \u2014 no lenses: dangling mappings, duplicates, leaks").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").option("--json", "print the findings as JSON (EXPERIMENTAL through 0.2.x)").action(
128
128
  (_opts, cmd) => action(async () => {
129
- const { check } = await import('./fleet-JHNQOLSY.js');
129
+ const { check } = await import('./fleet-6U6XW72I.js');
130
130
  const opts = cmd.optsWithGlobals();
131
131
  await check({ cwd: resolveCwd(cmd), manifest: opts.manifest, json: opts.json });
132
132
  })
@@ -136,7 +136,7 @@ fleet.command("add").argument("<dir>", "directory of the project to register").d
136
136
  "public-repo | public-bound | private \u2014 mandatory for personal entries"
137
137
  ).option("-y, --yes", "skip prompts; every mandatory value must be passed as a flag").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
138
138
  (dir, opts, cmd) => action(async () => {
139
- const { add } = await import('./fleet-JHNQOLSY.js');
139
+ const { add } = await import('./fleet-6U6XW72I.js');
140
140
  const shared = cmd.optsWithGlobals();
141
141
  await add({
142
142
  cwd: resolveCwd(cmd),
@@ -159,7 +159,7 @@ fleet.command("dismiss").argument("<name>", "the registered project name").argum
159
159
  "Dismiss a project's finding from any cwd \u2014 corp ledgers persist beside the manifest"
160
160
  ).requiredOption("--reason <text>", "why it is dismissed \u2014 recorded so the decision survives").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
161
161
  (name, id, _opts, cmd) => action(async () => {
162
- const { dismiss } = await import('./fleet-JHNQOLSY.js');
162
+ const { dismiss } = await import('./fleet-6U6XW72I.js');
163
163
  const opts = cmd.optsWithGlobals();
164
164
  await dismiss({
165
165
  cwd: resolveCwd(cmd),
@@ -174,7 +174,7 @@ fleet.command("accept").argument("<name>", "the registered project name").argume
174
174
  "Accept a project's finding as a known trade-off \u2014 corp ledgers persist beside the manifest"
175
175
  ).option("--reason <text>", "optional note on why the trade-off is accepted").option("--manifest <file>", "the fleet manifest \u2014 required unless the cwd holds registry.json").action(
176
176
  (name, id, _opts, cmd) => action(async () => {
177
- const { accept } = await import('./fleet-JHNQOLSY.js');
177
+ const { accept } = await import('./fleet-6U6XW72I.js');
178
178
  const opts = cmd.optsWithGlobals();
179
179
  await accept({ cwd: resolveCwd(cmd), manifest: opts.manifest, name, id, reason: opts.reason });
180
180
  })
@@ -196,7 +196,7 @@ program.command("screen").description(
196
196
  );
197
197
  program.command("gates").description("Install the local git-hook gates (process \u2192 pre-commit, correctness \u2192 pre-push)").option("--ci", "note about the CI review gate (ships later; local gates install now)").option("-y, --yes", "skip prompts; never overwrites a hand-edited hook").action(
198
198
  (opts, cmd) => action(async () => {
199
- const { run } = await import('./gates-KHC7HPIF.js');
199
+ const { run } = await import('./gates-N25LGIR2.js');
200
200
  await run({ cwd: resolveCwd(cmd), ci: opts.ci, yes: opts.yes });
201
201
  })
202
202
  );
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env node
2
- export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig } from './chunk-K7QBTANO.js';
2
+ export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig } from './chunk-D2RI3CCT.js';
3
3
  import './chunk-C7LBUFNU.js';
4
4
  import './chunk-IV3FYVTS.js';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { measureContext } from './chunk-OKU3HXIH.js';
3
- import { readConfig } from './chunk-K7QBTANO.js';
2
+ import { measureContext } from './chunk-JSUYV26K.js';
3
+ import { readConfig } from './chunk-D2RI3CCT.js';
4
4
  import { print, renderContext } from './chunk-5BVKFJWM.js';
5
5
  import './chunk-C7LBUFNU.js';
6
6
  import './chunk-IV3FYVTS.js';
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { run } from './chunk-BQGCLPHS.js';
3
- import './chunk-OESQNO2J.js';
4
- import './chunk-OKU3HXIH.js';
2
+ import { run } from './chunk-KBYEWHYX.js';
3
+ import './chunk-PRIH2NZF.js';
4
+ import './chunk-JSUYV26K.js';
5
5
  import './chunk-OAFYLBVG.js';
6
- import './chunk-K7QBTANO.js';
6
+ import './chunk-D2RI3CCT.js';
7
7
  import './chunk-5BVKFJWM.js';
8
- import './chunk-ULEWJ3ZK.js';
9
- import './chunk-HLWCODYX.js';
8
+ import './chunk-CIRNG736.js';
9
+ import './chunk-72Y2V7VF.js';
10
10
  import './chunk-C7LBUFNU.js';
11
11
  import './chunk-6DUDIVIC.js';
12
12
  import './chunk-IV3FYVTS.js';
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { dismiss, accept } from './chunk-V2SOHPXF.js';
3
- import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-OESQNO2J.js';
4
- import './chunk-OKU3HXIH.js';
3
+ import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-PRIH2NZF.js';
4
+ import './chunk-JSUYV26K.js';
5
5
  import { readLedger } from './chunk-OAFYLBVG.js';
6
- import { DEFAULT_CONFIG } from './chunk-K7QBTANO.js';
6
+ import { DEFAULT_CONFIG } from './chunk-D2RI3CCT.js';
7
7
  import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-5BVKFJWM.js';
8
- import { scanProject } from './chunk-ULEWJ3ZK.js';
9
- import './chunk-HLWCODYX.js';
8
+ import { scanProject } from './chunk-CIRNG736.js';
9
+ import './chunk-72Y2V7VF.js';
10
10
  import { ETYMD_DIR } from './chunk-C7LBUFNU.js';
11
11
  import './chunk-6DUDIVIC.js';
12
12
  import { readText, pathExists, git, isDirectory } from './chunk-IV3FYVTS.js';
@@ -687,9 +687,9 @@ async function checkCorpEmails(manifest, findings, disclosures) {
687
687
  }
688
688
  }
689
689
  async function checkGateDrift(manifest, findings, disclosures) {
690
- const { planWorkflow } = await import('./generate-UTKQ4GE6.js');
691
- const { scanProject: scanProject2 } = await import('./scan-D4M7JXE3.js');
692
- const { readConfig } = await import('./config-UJRSCDMD.js');
690
+ const { planWorkflow } = await import('./generate-IPY5QIR5.js');
691
+ const { scanProject: scanProject2 } = await import('./scan-LVJOAOUJ.js');
692
+ const { readConfig } = await import('./config-7WCVZCM6.js');
693
693
  for (const entry of manifest.entries) {
694
694
  const root = entry.resolvedRoot;
695
695
  if (!root || !await isDirectory(root)) continue;
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-EF6BPBG6.js';
3
- import { derivedCommands, planWorkflow, runPrefix, isSafeGateCommand } from './chunk-KTIEYKFK.js';
4
- import { readConfig, CONFIG_FILE, configPath } from './chunk-K7QBTANO.js';
3
+ import { deriveFailOn, derivedCommands, planWorkflow, runPrefix, isSafeGateCommand } from './chunk-WENIJMVV.js';
4
+ import { readConfig, CONFIG_FILE, configPath } from './chunk-D2RI3CCT.js';
5
5
  import { section, print, glyph, theme, renderPlan } from './chunk-5BVKFJWM.js';
6
- import { scanProject } from './chunk-ULEWJ3ZK.js';
7
- import './chunk-HLWCODYX.js';
6
+ import { scanProject } from './chunk-CIRNG736.js';
7
+ import './chunk-72Y2V7VF.js';
8
8
  import './chunk-C7LBUFNU.js';
9
9
  import './chunk-6DUDIVIC.js';
10
10
  import { readText, git } from './chunk-IV3FYVTS.js';
@@ -55,13 +55,30 @@ var PUBLISH_GATE_VALUE = `./${PUBLISH_GATE_SCRIPT}`;
55
55
  function publishGateKey(route) {
56
56
  return route === "vscode" ? "vscode:prepublish" : "prepublishOnly";
57
57
  }
58
- function printGateSummary(gates, facts, source) {
58
+ function printGateSummary(gates, facts, source, tier) {
59
59
  const run2 = runPrefix(facts.packageManager);
60
60
  const shellStep = facts.shell?.scripts ? `shellcheck (${facts.shell.scripts} script${facts.shell.scripts === 1 ? "" : "s"})` : "";
61
61
  const listed = [...gates.commands.map((c) => `${run2} ${c}`), ...shellStep ? [shellStep] : []];
62
62
  const cmds = listed.length ? listed.join(", ") : "none detected";
63
63
  print(` ${glyph.bullet} ${theme.dim("pre-push runs")} ${theme.info(cmds)}`);
64
- print(` ${glyph.bullet} ${theme.dim("audit fails on")} ${theme.info(gates.failOn)}`);
64
+ const origin = tier.source === "config" ? `set in ${CONFIG_FILE}` : tier.source === "derived" ? "derived \u2014 see below" : "default";
65
+ print(
66
+ ` ${glyph.bullet} ${theme.dim("audit fails on")} ${theme.info(gates.failOn)} ${theme.dim(`(${origin})`)}`
67
+ );
68
+ if (tier.reachable.length === 0) {
69
+ print(
70
+ ` ${theme.warn("no risk-tier finding can fire in this repo")} ${theme.dim("(no package manifest to contradict, no state doc to fall behind)")}`
71
+ );
72
+ print(
73
+ ` ${theme.dim(
74
+ gates.failOn === "risk" ? `\`--fail-on risk\` would be a gate that always passes \u2014 change it in ${CONFIG_FILE} under \`gates.failOn\`` : `so the tier was lowered to \`${gates.failOn}\`, which this repo can reach \u2014 pin or override it in ${CONFIG_FILE} under \`gates.failOn\``
75
+ )}`
76
+ );
77
+ } else if (gates.failOn === "risk") {
78
+ print(
79
+ ` ${theme.dim(`reachable at risk here: ${tier.reachable.join("; ")} \u2014 lower the tier in ${CONFIG_FILE} under \`gates.failOn\` to catch more`)}`
80
+ );
81
+ }
65
82
  print(
66
83
  ` ${glyph.bullet} ${theme.dim("publish screen")} ${theme.info(gates.publishGate ? "yes" : "no")}${gates.publishGate && !facts.publishable ? theme.dim(" (overridden)") : ""}`
67
84
  );
@@ -169,10 +186,16 @@ async function run(opts) {
169
186
  }
170
187
  }
171
188
  const existingPrePush = await readText(path.join(opts.cwd, ".githooks", "pre-push"));
172
- const { config, present: hasConfig } = await readConfig(opts.cwd);
189
+ const { config, present: hasConfig, explicit } = await readConfig(opts.cwd);
173
190
  const configSource = { recorded: hasConfig, willRecord: !opts.yes };
191
+ const derivedTier = deriveFailOn(facts, {
192
+ failOn: config.gates.failOn,
193
+ explicit: explicit.gatesFailOn
194
+ });
195
+ let tier = { source: derivedTier.source, reachable: derivedTier.reachable };
174
196
  let gateConfig = {
175
197
  ...config.gates,
198
+ failOn: derivedTier.failOn,
176
199
  commands: config.gates.commands.length ? config.gates.commands : derivedCommands(facts, existingPrePush ?? void 0),
177
200
  publishGate: config.gates.publishGate ?? facts.publishable
178
201
  };
@@ -186,7 +209,7 @@ async function run(opts) {
186
209
  };
187
210
  let gateFiles = await plan();
188
211
  renderPlan(gateFiles, { regeneratesStale: true });
189
- printGateSummary(gateConfig, facts, configSource);
212
+ printGateSummary(gateConfig, facts, configSource, tier);
190
213
  if (!opts.yes) {
191
214
  const choice = await select({
192
215
  message: "Install these gates?",
@@ -208,9 +231,10 @@ async function run(opts) {
208
231
  return;
209
232
  }
210
233
  gateConfig = customized;
234
+ tier = { source: "config", reachable: derivedTier.reachable };
211
235
  gateFiles = await plan();
212
236
  renderPlan(gateFiles);
213
- printGateSummary(gateConfig, facts, configSource);
237
+ printGateSummary(gateConfig, facts, configSource, tier);
214
238
  }
215
239
  await writeGateConfig(opts.cwd, gateConfig);
216
240
  }
@@ -247,6 +271,11 @@ async function run(opts) {
247
271
  }
248
272
  }
249
273
  print(` ${glyph.ok} ${theme.dim("set")} ${theme.code("core.hooksPath = .githooks")}`);
274
+ print(
275
+ ` ${glyph.ok} ${theme.dim("pre-push audit fails on")} ${theme.info(gateConfig.failOn)} ${theme.dim(
276
+ tier.source === "config" ? `(from ${CONFIG_FILE})` : tier.source === "derived" ? `(derived \u2014 no risk-tier finding can fire here; set \`gates.failOn\` in ${CONFIG_FILE} to pin a tier)` : `(default \u2014 set \`gates.failOn\` in ${CONFIG_FILE} to change it)`
277
+ )}`
278
+ );
250
279
  const wrotePublishGate = result.written.includes(PUBLISH_GATE_SCRIPT);
251
280
  if (wrotePublishGate) {
252
281
  const pkgPath = path.join(opts.cwd, "package.json");
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ export { deriveFailOn, derivedCommands, planWorkflow, riskReachability } from './chunk-WENIJMVV.js';
3
+ import './chunk-D2RI3CCT.js';
4
+ import './chunk-C7LBUFNU.js';
5
+ import './chunk-6DUDIVIC.js';
6
+ import './chunk-IV3FYVTS.js';
package/dist/index.d.ts CHANGED
@@ -241,6 +241,15 @@ interface LoadedConfig {
241
241
  present: boolean;
242
242
  /** Malformed or ignored input — surfaced as disclosures, never silently dropped. */
243
243
  problems: string[];
244
+ /**
245
+ * Keys the file actually set, as opposed to keys that collapsed to a default. `gates.failOn`
246
+ * defaults to the same word a user might write deliberately, so the resolved value cannot say
247
+ * which happened — and a derivation may adjust a default it chose while never touching a tier
248
+ * someone decided on.
249
+ */
250
+ explicit: {
251
+ gatesFailOn: boolean;
252
+ };
244
253
  }
245
254
  declare const DEFAULT_CONFIG: EtymdConfig;
246
255
  declare function configPath(root: string): string;
@@ -721,6 +730,13 @@ declare const contextEconomyLens: Lens;
721
730
  * The marker that opts a decisions file into per-entry format checks. Forward-only by design:
722
731
  * mandatory-field checks apply to files that declared the format, never retroactively to
723
732
  * pre-existing records — old decisions are history, not defects.
733
+ *
734
+ * A file may append `fields=A,B` to require field names of its own choosing on every entry at or
735
+ * after the marker — position is the gate, so a marker moved down the file exempts the history
736
+ * above it and one left at the top governs the whole file.
737
+ * Etymd ships no vocabulary for those names and attaches no meaning to them: it checks only that
738
+ * a field the file itself declared is present. A marker with no `fields=` behaves as it always
739
+ * has, so the attribute is an extension rather than a new format version.
724
740
  */
725
741
  declare const DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
726
742
  /**
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.6.0",
33
+ version: "0.8.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) => {
@@ -2617,7 +2638,53 @@ init_config();
2617
2638
  init_util();
2618
2639
  var LENS_ID4 = "state-freshness";
2619
2640
  var DECISIONS_FORMAT_MARKER = "<!-- decisions-format: 1 -->";
2641
+ var KNOWN_FORMAT_VERSION = 1;
2642
+ var MARKER_RE = /<!--\s*decisions-format:\s*(\d+)([^>]*?)-->/;
2643
+ var FIELD_NAME_RE = /^[A-Za-z0-9 _-]+$/;
2644
+ var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["scope"]);
2620
2645
  var MS_PER_DAY = 864e5;
2646
+ function parseDecisionsFormat(text) {
2647
+ const m = MARKER_RE.exec(text);
2648
+ if (!m) return null;
2649
+ const problems = [];
2650
+ const fields = [];
2651
+ const offset = m.index ?? 0;
2652
+ const version = Number(m[1]);
2653
+ if (version !== KNOWN_FORMAT_VERSION) {
2654
+ problems.push(
2655
+ `declares decisions-format version ${version}; this etymd understands version ${KNOWN_FORMAT_VERSION} \u2014 checked as version ${KNOWN_FORMAT_VERSION}.`
2656
+ );
2657
+ }
2658
+ const attrs = (m[2] ?? "").trim();
2659
+ if (!attrs) return { fields, offset, problems };
2660
+ const declared = /^fields=(.*)$/.exec(attrs);
2661
+ if (!declared) {
2662
+ problems.push(`marker attribute \`${attrs}\` is not understood \u2014 ignored (only \`fields=\`).`);
2663
+ return { fields, offset, problems };
2664
+ }
2665
+ const seen = new Set(BUILT_IN_FIELDS);
2666
+ for (const raw of declared[1].split(",")) {
2667
+ const name = raw.trim();
2668
+ if (!name) continue;
2669
+ if (!FIELD_NAME_RE.test(name)) {
2670
+ problems.push(
2671
+ `declared field \`${name}\` is not a usable field name (letters, digits, spaces, \`-\`, \`_\`) \u2014 not checked.`
2672
+ );
2673
+ continue;
2674
+ }
2675
+ const key = name.toLowerCase();
2676
+ if (seen.has(key)) continue;
2677
+ seen.add(key);
2678
+ fields.push(name);
2679
+ }
2680
+ if (fields.length === 0 && problems.length === 0) {
2681
+ problems.push("marker declares `fields=` with no field names \u2014 no extra fields checked.");
2682
+ }
2683
+ return { fields, offset, problems };
2684
+ }
2685
+ function hasField(block, name) {
2686
+ return new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s*]*:`).test(block);
2687
+ }
2621
2688
  function parseDecisionEntries(text) {
2622
2689
  const headings = [...text.matchAll(/^## .*$/gm)];
2623
2690
  const entries = [];
@@ -2625,9 +2692,10 @@ function parseDecisionEntries(text) {
2625
2692
  const h = headings[i];
2626
2693
  const m = /^## (D-(\d+))\b/.exec(h[0]);
2627
2694
  if (!m) continue;
2628
- const start = (h.index ?? 0) + h[0].length;
2695
+ const offset = h.index ?? 0;
2696
+ const start = offset + h[0].length;
2629
2697
  const end = i + 1 < headings.length ? headings[i + 1].index : void 0;
2630
- entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end) });
2698
+ entries.push({ id: m[1], num: Number(m[2]), block: text.slice(start, end), offset });
2631
2699
  }
2632
2700
  return entries;
2633
2701
  }
@@ -2673,10 +2741,25 @@ function checkIdSequence(file, entries) {
2673
2741
  }
2674
2742
  return findings;
2675
2743
  }
2676
- function checkFormatFields(file, entries, today) {
2744
+ function checkFormatFields(file, entries, today, declaredFields, markerOffset) {
2677
2745
  const findings = [];
2678
2746
  for (const entry of entries) {
2679
- if (!/Scope[\s*]*:/.test(entry.block)) {
2747
+ const bound = entry.offset >= markerOffset;
2748
+ for (const field of bound ? declaredFields : []) {
2749
+ if (hasField(entry.block, field)) continue;
2750
+ findings.push({
2751
+ id: `${LENS_ID4}/field-missing:${file}:${entry.id}:${field}`,
2752
+ lens: LENS_ID4,
2753
+ tier: "gap",
2754
+ claim: `${file} ${entry.id} has no ${field}: field`,
2755
+ evidence: [`${file}: ${entry.id}`, `${file} marker declares required field \`${field}\``],
2756
+ why: "The file declares this field required on every entry after the marker; whatever reads the record for it finds nothing here.",
2757
+ action: `Add a ${field}: line to ${entry.id}.`,
2758
+ effort: "S",
2759
+ confidence: "high"
2760
+ });
2761
+ }
2762
+ if (bound && !/Scope[\s*]*:/.test(entry.block)) {
2680
2763
  findings.push({
2681
2764
  id: `${LENS_ID4}/scope-missing:${file}:${entry.id}`,
2682
2765
  lens: LENS_ID4,
@@ -2791,14 +2874,27 @@ var stateFreshnessLens = {
2791
2874
  }
2792
2875
  const entries = parseDecisionEntries(text);
2793
2876
  findings.push(...checkIdSequence(a.path, entries));
2794
- if (!text.includes(DECISIONS_FORMAT_MARKER)) {
2877
+ const format = parseDecisionsFormat(text);
2878
+ if (!format) {
2795
2879
  disclosures.push(
2796
2880
  `${a.path} carries no \`${DECISIONS_FORMAT_MARKER}\` marker \u2014 format checks skipped (forward-only, never retroactive); id-sequence checks still ran.`
2797
2881
  );
2798
2882
  outOfScope.push(a.path);
2799
2883
  continue;
2800
2884
  }
2801
- findings.push(...checkFormatFields(a.path, entries, today));
2885
+ for (const problem of format.problems) disclosures.push(`${a.path}: ${problem}`);
2886
+ const exempt = entries.filter((e) => e.offset < format.offset);
2887
+ if (format.fields.length > 0) {
2888
+ disclosures.push(
2889
+ `${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).`
2890
+ );
2891
+ }
2892
+ if (exempt.length > 0) {
2893
+ disclosures.push(
2894
+ `${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).`
2895
+ );
2896
+ }
2897
+ findings.push(...checkFormatFields(a.path, entries, today, format.fields, format.offset));
2802
2898
  }
2803
2899
  disclosures.push(
2804
2900
  `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.`
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-EF6BPBG6.js';
3
- import { planWorkflow } from './chunk-KTIEYKFK.js';
4
- import './chunk-K7QBTANO.js';
3
+ import { planWorkflow } from './chunk-WENIJMVV.js';
4
+ import './chunk-D2RI3CCT.js';
5
5
  import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-5BVKFJWM.js';
6
- import { scanProject } from './chunk-ULEWJ3ZK.js';
7
- import { VERSION } from './chunk-HLWCODYX.js';
6
+ import { scanProject } from './chunk-CIRNG736.js';
7
+ import { VERSION } from './chunk-72Y2V7VF.js';
8
8
  import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-C7LBUFNU.js';
9
9
  import { PACK_VERSION } from './chunk-6DUDIVIC.js';
10
10
  import { git, readText } from './chunk-IV3FYVTS.js';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { print, renderFacts, theme } from './chunk-5BVKFJWM.js';
3
- import { scanProject } from './chunk-ULEWJ3ZK.js';
4
- import './chunk-HLWCODYX.js';
3
+ import { scanProject } from './chunk-CIRNG736.js';
4
+ import './chunk-72Y2V7VF.js';
5
5
  import { writeCachedFacts } from './chunk-C7LBUFNU.js';
6
6
  import './chunk-6DUDIVIC.js';
7
7
  import './chunk-IV3FYVTS.js';
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-CIRNG736.js';
3
+ import './chunk-72Y2V7VF.js';
4
+ import './chunk-6DUDIVIC.js';
5
+ import './chunk-IV3FYVTS.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etymd",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Keep your agent instructions true — verify AGENTS.md, CLAUDE.md, rules & skills against the actual repo, with drift caught over time and a regression ledger.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,12 +0,0 @@
1
- #!/usr/bin/env node
2
- export { run } from './chunk-BQGCLPHS.js';
3
- import './chunk-OESQNO2J.js';
4
- import './chunk-OKU3HXIH.js';
5
- import './chunk-OAFYLBVG.js';
6
- import './chunk-K7QBTANO.js';
7
- import './chunk-5BVKFJWM.js';
8
- import './chunk-ULEWJ3ZK.js';
9
- import './chunk-HLWCODYX.js';
10
- import './chunk-C7LBUFNU.js';
11
- import './chunk-6DUDIVIC.js';
12
- import './chunk-IV3FYVTS.js';
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env node
2
- export { derivedCommands, planWorkflow } from './chunk-KTIEYKFK.js';
3
- import './chunk-K7QBTANO.js';
4
- import './chunk-C7LBUFNU.js';
5
- import './chunk-6DUDIVIC.js';
6
- import './chunk-IV3FYVTS.js';
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export { scanProject } from './chunk-ULEWJ3ZK.js';
3
- import './chunk-HLWCODYX.js';
4
- import './chunk-6DUDIVIC.js';
5
- import './chunk-IV3FYVTS.js';