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/CHANGELOG.md +26 -0
- package/dist/{approve-TXF3MA3G.js → approve-H4UMLKRJ.js} +2 -2
- package/dist/audit-22SOGKLY.js +12 -0
- package/dist/{brief-QUYHRWH6.js → brief-VKNKPZJF.js} +2 -2
- package/dist/{chunk-LXEYYITD.js → chunk-5P7KZV4W.js} +390 -286
- package/dist/{chunk-K7QBTANO.js → chunk-D2RI3CCT.js} +4 -3
- package/dist/{chunk-OGIHOTLP.js → chunk-E3CVCK5X.js} +1 -1
- package/dist/{chunk-OKU3HXIH.js → chunk-JSUYV26K.js} +1 -1
- package/dist/{chunk-XJMN2HMH.js → chunk-MUNRU2LF.js} +1 -1
- package/dist/{chunk-KTIEYKFK.js → chunk-WENIJMVV.js} +20 -2
- package/dist/{chunk-7LVCGCHB.js → chunk-ZA7GVR63.js} +1 -1
- package/dist/cli.js +14 -14
- package/dist/{config-UJRSCDMD.js → config-7WCVZCM6.js} +1 -1
- package/dist/{context-ZEUI5ESZ.js → context-5ANOP2IJ.js} +2 -2
- package/dist/{doctor-2BU6WCI6.js → doctor-ITR74ZUE.js} +6 -6
- package/dist/{fleet-3UQ65X4Y.js → fleet-CTBMXRHV.js} +8 -8
- package/dist/{gates-MYNOZCON.js → gates-X7WY7IRK.js} +38 -9
- package/dist/generate-IPY5QIR5.js +6 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +414 -289
- package/dist/{init-52PR24Q4.js → init-ROJ6LQX3.js} +4 -4
- package/dist/{scan-GSDICQCX.js → scan-2ODW52EK.js} +2 -2
- package/dist/scan-VL4DW5ZT.js +5 -0
- package/package.json +1 -1
- package/dist/audit-354X7QN6.js +0 -12
- package/dist/generate-UTKQ4GE6.js +0 -6
- package/dist/scan-M6RKDNYR.js +0 -5
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.
|
|
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
|
|
2655
|
+
var LENS_ID4 = "instruction-truth";
|
|
2323
2656
|
var MAX_PATH_FINDINGS_PER_FILE = 15;
|
|
2324
2657
|
function finding2(partial) {
|
|
2325
|
-
return { lens:
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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:
|
|
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: `${
|
|
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
|
-
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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: `${
|
|
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:
|
|
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 };
|