etymd 0.13.0 → 0.14.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 +94 -0
- package/dist/{approve-YUT43YLC.js → approve-QDOH2E7V.js} +5 -5
- package/dist/audit-42OHF7ON.js +10 -0
- package/dist/{brief-Z5S6OY2M.js → brief-ZUQK3CFL.js} +5 -5
- package/dist/{chunk-D3R74TJ2.js → chunk-623W6KVC.js} +1 -1
- package/dist/{chunk-5BVKFJWM.js → chunk-HI7NWPRA.js} +79 -4
- package/dist/{chunk-2VLNI3L2.js → chunk-HOR4M6EC.js} +1 -1
- package/dist/{chunk-YXOAPMQH.js → chunk-HWC6PKJM.js} +2 -2
- package/dist/{chunk-PXRLOEN5.js → chunk-LT7JMVV3.js} +127 -11
- package/dist/{chunk-DWL2IKZH.js → chunk-P6ATKV2R.js} +65 -3
- package/dist/{chunk-DSAQ5S5D.js → chunk-PAMWS665.js} +2 -2
- package/dist/{chunk-HRJJQCMT.js → chunk-UFNETE6P.js} +69 -20
- package/dist/{chunk-LSZGCKIQ.js → chunk-YQZDYDAK.js} +1 -1
- package/dist/cli.js +20 -20
- package/dist/{config-XAH6PA5G.js → config-724Y3IOB.js} +1 -2
- package/dist/{context-F63RSIBH.js → context-JGKU4M7Z.js} +2 -4
- package/dist/doctor-CE5SD4JW.js +17 -0
- package/dist/{fleet-YQ35KGEP.js → fleet-F3XDEDCV.js} +10 -12
- package/dist/{gates-DU6SDH2M.js → gates-F4YPKWJR.js} +6 -7
- package/dist/generate-KEX75XNG.js +5 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +223 -40
- package/dist/{init-XJEYU2HC.js → init-AHA7VRRI.js} +14 -13
- package/dist/ledger-T54JDHGP.js +6 -0
- package/dist/scan-QRTH6HAP.js +5 -0
- package/dist/{scan-B24RZAV2.js → scan-TVX3FKK4.js} +5 -5
- package/dist/{screen-CS6PSG7U.js → screen-E4FC7W5N.js} +2 -1
- package/package.json +1 -1
- package/dist/audit-YRT4SWSQ.js +0 -12
- package/dist/chunk-F75Q43BC.js +0 -59
- package/dist/chunk-JHZ2BN4U.js +0 -67
- package/dist/doctor-7YKDXSKM.js +0 -19
- package/dist/generate-PMCP37DC.js +0 -6
- package/dist/ledger-4FOZ5HAB.js +0 -5
- package/dist/scan-QDNN7ER3.js +0 -5
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ var __export = (target, all) => {
|
|
|
20
20
|
var PACK_VERSION;
|
|
21
21
|
var init_version = __esm({
|
|
22
22
|
"src/pack/version.ts"() {
|
|
23
|
-
PACK_VERSION = "
|
|
23
|
+
PACK_VERSION = "12";
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -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.14.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",
|
|
@@ -1136,15 +1136,36 @@ ${[
|
|
|
1136
1136
|
"md"
|
|
1137
1137
|
);
|
|
1138
1138
|
}
|
|
1139
|
+
function isSelfBuildRepo(facts) {
|
|
1140
|
+
return facts.publishRoute !== "none" && facts.name === SELF_PACKAGE_NAME;
|
|
1141
|
+
}
|
|
1142
|
+
function contentGateResolution(selfBuild) {
|
|
1143
|
+
if (!selfBuild) return `GATE="\${CONTENT_GATE:-$(command -v etymd || true)}"`;
|
|
1144
|
+
return `GATE="\${CONTENT_GATE:-$(if [ -x ./dist/cli.js ]; then echo ./dist/cli.js; else command -v etymd || true; fi)}"`;
|
|
1145
|
+
}
|
|
1146
|
+
function contentScreenCall(opts) {
|
|
1147
|
+
const { args, envVar, blocking, indent } = opts;
|
|
1148
|
+
const hint = `etymd: this checker does not understand 'screen' (needs etymd 0.11+) \u2014 upgrade it, or set ${envVar} to a checker that does.`;
|
|
1149
|
+
return [
|
|
1150
|
+
`${indent}if ! "$GATE" screen ${args}; then`,
|
|
1151
|
+
`${indent} "$GATE" screen --help >/dev/null 2>&1 ||`,
|
|
1152
|
+
`${indent} echo "${hint}" >&2`,
|
|
1153
|
+
...blocking ? [`${indent} exit 1`] : [],
|
|
1154
|
+
`${indent}fi`
|
|
1155
|
+
].join("\n");
|
|
1156
|
+
}
|
|
1139
1157
|
function localHookCall(hook) {
|
|
1140
1158
|
return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
|
|
1141
1159
|
# is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
|
|
1160
|
+
# A guard running tests that build fixture repositories should scrub git's exported GIT_* names
|
|
1161
|
+
# first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
|
|
1162
|
+
# env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
|
|
1142
1163
|
LOCAL="$(dirname "$0")/${hook}.local"
|
|
1143
1164
|
if [ -x "$LOCAL" ]; then
|
|
1144
1165
|
"$LOCAL" "$@" || exit 1
|
|
1145
1166
|
fi`;
|
|
1146
1167
|
}
|
|
1147
|
-
function generatePreCommitHook() {
|
|
1168
|
+
function generatePreCommitHook(selfBuild = false) {
|
|
1148
1169
|
return stampGenerated(`#!/usr/bin/env sh
|
|
1149
1170
|
# etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
|
|
1150
1171
|
|
|
@@ -1156,9 +1177,9 @@ ${localHookCall("pre-commit")}
|
|
|
1156
1177
|
# commit anywhere, active only where you opted in.
|
|
1157
1178
|
#
|
|
1158
1179
|
# Bypass, with a reason: git commit --no-verify
|
|
1159
|
-
${
|
|
1180
|
+
${contentGateResolution(selfBuild)}
|
|
1160
1181
|
if [ -x "$GATE" ]; then
|
|
1161
|
-
|
|
1182
|
+
${contentScreenCall({ args: "--staged", envVar: "CONTENT_GATE", blocking: true, indent: " " })}
|
|
1162
1183
|
fi
|
|
1163
1184
|
|
|
1164
1185
|
exit 0
|
|
@@ -1199,7 +1220,7 @@ function generateCommitMsgHook(gates) {
|
|
|
1199
1220
|
# Bypass, with a reason: git commit --no-verify
|
|
1200
1221
|
GATE="\${COMMIT_MSG_GATE:-$(command -v etymd || true)}"
|
|
1201
1222
|
if [ -x "$GATE" ]; then
|
|
1202
|
-
|
|
1223
|
+
${contentScreenCall({ args: '--message "$1"', envVar: "COMMIT_MSG_GATE", blocking: true, indent: " " })}
|
|
1203
1224
|
fi
|
|
1204
1225
|
${format}
|
|
1205
1226
|
${localHookCall("commit-msg")}
|
|
@@ -1210,11 +1231,24 @@ exit 0
|
|
|
1210
1231
|
function shellcheckStep() {
|
|
1211
1232
|
return `
|
|
1212
1233
|
# Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
|
|
1213
|
-
# script added later is covered without regenerating this hook.
|
|
1234
|
+
# script added later is covered without regenerating this hook. zsh is NOT in the checked set:
|
|
1235
|
+
# the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
|
|
1236
|
+
# so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
|
|
1237
|
+
# at run time below, because a coverage hole that is silent is indistinguishable from coverage.
|
|
1238
|
+
#
|
|
1239
|
+
# "the checker", not its name, on purpose: a comment whose first word is that name is read as
|
|
1240
|
+
# a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
|
|
1241
|
+
# explains why it skips a shell dialect must not break the checker while doing it.
|
|
1214
1242
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
1215
1243
|
scripts=$(git ls-files -z \\
|
|
1216
|
-
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da
|
|
1244
|
+
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ](ba|da)?sh( |$)" && echo "{}"' \\
|
|
1217
1245
|
| sort)
|
|
1246
|
+
zsh_scripts=$(git ls-files -z \\
|
|
1247
|
+
| xargs -0 -I{} sh -c 'head -1 "{}" 2>/dev/null | grep -qE "^#!.*[/ ]zsh( |$)" && echo "{}"' \\
|
|
1248
|
+
| sort)
|
|
1249
|
+
if [ -n "$zsh_scripts" ]; then
|
|
1250
|
+
echo "\u203A shellcheck: $(printf '%s\\n' "$zsh_scripts" | wc -l | tr -d ' ') zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
|
|
1251
|
+
fi
|
|
1218
1252
|
if [ -n "$scripts" ]; then
|
|
1219
1253
|
echo "\u203A shellcheck ($(printf '%s\\n' "$scripts" | wc -l | tr -d ' ') scripts, blocking at severity=warning)"
|
|
1220
1254
|
printf '%s\\n' "$scripts" | xargs shellcheck -S warning || {
|
|
@@ -1234,7 +1268,7 @@ else
|
|
|
1234
1268
|
echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
|
|
1235
1269
|
fi`;
|
|
1236
1270
|
}
|
|
1237
|
-
function generatePrePushHook(facts, gates) {
|
|
1271
|
+
function generatePrePushHook(facts, gates, selfBuild = false) {
|
|
1238
1272
|
const run = runPrefix(facts.packageManager);
|
|
1239
1273
|
const c = facts.commands;
|
|
1240
1274
|
const candidates = gates?.commands?.length ? gates.commands : [c.formatCheck, c.typecheck, c.lint];
|
|
@@ -1243,8 +1277,9 @@ function generatePrePushHook(facts, gates) {
|
|
|
1243
1277
|
(key) => Boolean(key) && (allowed.has(key) || isSafeGateCommand(c.raw[key]))
|
|
1244
1278
|
).map((key) => `${run} ${key}`);
|
|
1245
1279
|
const shellStep = facts.shell?.scripts ? shellcheckStep() : "";
|
|
1280
|
+
const runner = steps.length ? SCRUBBED_RUNNER : "";
|
|
1246
1281
|
const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
|
|
1247
|
-
${s} || exit 1`).join("\n") : shellStep ? (
|
|
1282
|
+
run_gate ${s} || exit 1`).join("\n") : shellStep ? (
|
|
1248
1283
|
// A repo whose executable surface is shell HAS a correctness command — it just is not in
|
|
1249
1284
|
// package.json. Claiming "none detected" beside a step that is about to run would be the
|
|
1250
1285
|
// tool contradicting itself.
|
|
@@ -1261,22 +1296,22 @@ fi`;
|
|
|
1261
1296
|
return stampGenerated(`#!/usr/bin/env sh
|
|
1262
1297
|
# etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
|
|
1263
1298
|
|
|
1264
|
-
${localHookCall("pre-push")}
|
|
1299
|
+
${localHookCall("pre-push")}${runner}
|
|
1265
1300
|
${body}${shellStep}
|
|
1266
1301
|
${auditStep}
|
|
1267
1302
|
|
|
1268
1303
|
# Content screen, second pass \u2014 the WHOLE TREE rather than one diff. Catches anything committed
|
|
1269
1304
|
# with --no-verify and anything a rebase or merge brought in from elsewhere. Advisory here (it
|
|
1270
1305
|
# never blocks the push): the blocking decision belongs at commit time, where the fix is cheap.
|
|
1271
|
-
${
|
|
1306
|
+
${contentGateResolution(selfBuild)}
|
|
1272
1307
|
if [ -x "$GATE" ]; then
|
|
1273
|
-
|
|
1308
|
+
${contentScreenCall({ args: "--tree --advisory", envVar: "CONTENT_GATE", blocking: false, indent: " " })}
|
|
1274
1309
|
fi
|
|
1275
1310
|
|
|
1276
1311
|
exit 0
|
|
1277
1312
|
`);
|
|
1278
1313
|
}
|
|
1279
|
-
function generateArtifactCheckScript() {
|
|
1314
|
+
function generateArtifactCheckScript(selfBuild = false) {
|
|
1280
1315
|
return stampGenerated(`#!/usr/bin/env sh
|
|
1281
1316
|
# etymd: content screen \u2014 the published ARTIFACT, not the repository.
|
|
1282
1317
|
#
|
|
@@ -1287,7 +1322,7 @@ function generateArtifactCheckScript() {
|
|
|
1287
1322
|
# .etymd-screen-allow entries (with provenance) if you must exempt a string.
|
|
1288
1323
|
set -eu
|
|
1289
1324
|
|
|
1290
|
-
${
|
|
1325
|
+
${contentGateResolution(selfBuild)}
|
|
1291
1326
|
[ -x "$GATE" ] || { echo "\u203A artifact-check: no checker installed \u2014 skipping."; exit 0; }
|
|
1292
1327
|
|
|
1293
1328
|
WORK=$(mktemp -d)
|
|
@@ -1300,16 +1335,16 @@ if [ -f package.json ]; then
|
|
|
1300
1335
|
tar -xzf "$WORK"/*.tgz -C "$WORK" 2>/dev/null || true
|
|
1301
1336
|
fi
|
|
1302
1337
|
|
|
1303
|
-
|
|
1338
|
+
${contentScreenCall({ args: '--dir "$WORK"', envVar: "CONTENT_GATE", blocking: true, indent: "" })}
|
|
1304
1339
|
exit 0
|
|
1305
1340
|
`);
|
|
1306
1341
|
}
|
|
1307
|
-
var GENERATION_MARKER_RE,
|
|
1342
|
+
var GENERATION_MARKER_RE, SELF_PACKAGE_NAME, COMMIT_TYPES, SUBJECT_ADVISORY_LENGTH, SCRUBBED_RUNNER;
|
|
1308
1343
|
var init_templates = __esm({
|
|
1309
1344
|
"src/pack/templates.ts"() {
|
|
1310
1345
|
init_version();
|
|
1311
1346
|
GENERATION_MARKER_RE = /^(?:# |<!-- )etymd:generated pack-v\S+ ([0-9a-f]{16})(?: -->)?$/;
|
|
1312
|
-
|
|
1347
|
+
SELF_PACKAGE_NAME = "etymd";
|
|
1313
1348
|
COMMIT_TYPES = [
|
|
1314
1349
|
"feat",
|
|
1315
1350
|
"fix",
|
|
@@ -1324,6 +1359,14 @@ var init_templates = __esm({
|
|
|
1324
1359
|
"revert"
|
|
1325
1360
|
];
|
|
1326
1361
|
SUBJECT_ADVISORY_LENGTH = 72;
|
|
1362
|
+
SCRUBBED_RUNNER = `
|
|
1363
|
+
# Gate steps run scrubbed of git's exported GIT_* names: a child git that inherits them ignores
|
|
1364
|
+
# its cwd, so a hook-run suite building fixture repositories would operate on the real repo.
|
|
1365
|
+
run_gate() (
|
|
1366
|
+
# shellcheck disable=SC2046 # word-splitting is the point: one -u per exported GIT_* name
|
|
1367
|
+
env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') "$@"
|
|
1368
|
+
)
|
|
1369
|
+
`;
|
|
1327
1370
|
}
|
|
1328
1371
|
});
|
|
1329
1372
|
|
|
@@ -1386,7 +1429,13 @@ async function planWorkflow(root, facts, opts) {
|
|
|
1386
1429
|
}
|
|
1387
1430
|
if (opts.gates) {
|
|
1388
1431
|
const existingPrePush = await readText(path2.join(root, ".githooks", "pre-push"));
|
|
1389
|
-
|
|
1432
|
+
const selfBuild = isSelfBuildRepo(facts);
|
|
1433
|
+
await add(
|
|
1434
|
+
".githooks/pre-commit",
|
|
1435
|
+
generatePreCommitHook(selfBuild),
|
|
1436
|
+
"Process gate (pre-commit)",
|
|
1437
|
+
true
|
|
1438
|
+
);
|
|
1390
1439
|
await add(
|
|
1391
1440
|
".githooks/commit-msg",
|
|
1392
1441
|
generateCommitMsgHook(opts.gateConfig),
|
|
@@ -1402,14 +1451,14 @@ async function planWorkflow(root, facts, opts) {
|
|
|
1402
1451
|
};
|
|
1403
1452
|
await add(
|
|
1404
1453
|
".githooks/pre-push",
|
|
1405
|
-
generatePrePushHook(facts, gateConfig),
|
|
1454
|
+
generatePrePushHook(facts, gateConfig, selfBuild),
|
|
1406
1455
|
"Correctness gate (pre-push)",
|
|
1407
1456
|
true
|
|
1408
1457
|
);
|
|
1409
1458
|
if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
|
|
1410
1459
|
await add(
|
|
1411
1460
|
"scripts/artifact-check.sh",
|
|
1412
|
-
generateArtifactCheckScript(),
|
|
1461
|
+
generateArtifactCheckScript(selfBuild),
|
|
1413
1462
|
"Content screen (published artifact)",
|
|
1414
1463
|
true
|
|
1415
1464
|
);
|
|
@@ -1446,14 +1495,36 @@ function isAlwaysAppliedCursorRule(text) {
|
|
|
1446
1495
|
const frontmatter = fm[1] ?? "";
|
|
1447
1496
|
return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
|
|
1448
1497
|
}
|
|
1498
|
+
async function inodeKey(abs) {
|
|
1499
|
+
try {
|
|
1500
|
+
const st = await promises.stat(abs);
|
|
1501
|
+
return st.ino ? `${st.dev}:${st.ino}` : null;
|
|
1502
|
+
} catch {
|
|
1503
|
+
return null;
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
function contextFileLabel(file) {
|
|
1507
|
+
return file.aliases?.length ? [file.path, ...file.aliases].join(" \u2192 ") : file.path;
|
|
1508
|
+
}
|
|
1449
1509
|
async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
1450
1510
|
const files = [];
|
|
1511
|
+
const byInode = /* @__PURE__ */ new Map();
|
|
1512
|
+
const record = async (rel, role, text) => {
|
|
1513
|
+
const words = wordCount(text);
|
|
1514
|
+
const key = await inodeKey(path2.join(root, rel));
|
|
1515
|
+
const seen = key ? byInode.get(key) : void 0;
|
|
1516
|
+
if (seen) {
|
|
1517
|
+
seen.aliases = [...seen.aliases ?? [], rel];
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const file = { path: rel, role, words, approxTokens: approxTokens(words) };
|
|
1521
|
+
if (key) byInode.set(key, file);
|
|
1522
|
+
files.push(file);
|
|
1523
|
+
};
|
|
1451
1524
|
for (const spec of ALWAYS_LOADED) {
|
|
1452
|
-
const
|
|
1453
|
-
const text = await readText(abs);
|
|
1525
|
+
const text = await readText(path2.join(root, spec.path));
|
|
1454
1526
|
if (text === null) continue;
|
|
1455
|
-
|
|
1456
|
-
files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
|
|
1527
|
+
await record(spec.path, spec.role, text);
|
|
1457
1528
|
}
|
|
1458
1529
|
const rulesDir = path2.join(root, ".cursor", "rules");
|
|
1459
1530
|
if (await pathExists(rulesDir)) {
|
|
@@ -1462,13 +1533,7 @@ async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
|
1462
1533
|
if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
|
|
1463
1534
|
const text = await readText(path2.join(rulesDir, entry));
|
|
1464
1535
|
if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
|
|
1465
|
-
|
|
1466
|
-
files.push({
|
|
1467
|
-
path: `.cursor/rules/${entry}`,
|
|
1468
|
-
role: "Cursor rule (always applied)",
|
|
1469
|
-
words,
|
|
1470
|
-
approxTokens: approxTokens(words)
|
|
1471
|
-
});
|
|
1536
|
+
await record(`.cursor/rules/${entry}`, "Cursor rule (always applied)", text);
|
|
1472
1537
|
}
|
|
1473
1538
|
} catch {
|
|
1474
1539
|
}
|
|
@@ -1525,13 +1590,15 @@ var contextEconomyLens = {
|
|
|
1525
1590
|
const budgets = ctx.config?.config.context ?? DEFAULT_CONFIG.context;
|
|
1526
1591
|
const budget = await measureContext(ctx.root, budgets.perFileWords);
|
|
1527
1592
|
const findings = [];
|
|
1593
|
+
const aliased = budget.files.filter((f) => f.aliases?.length);
|
|
1528
1594
|
for (const f of budget.extractionCandidates) {
|
|
1595
|
+
const label = contextFileLabel(f);
|
|
1529
1596
|
findings.push({
|
|
1530
1597
|
id: `${LENS_ID}/heavy-file:${f.path}`,
|
|
1531
1598
|
lens: LENS_ID,
|
|
1532
1599
|
tier: "gap",
|
|
1533
|
-
claim: `${
|
|
1534
|
-
evidence: [`${
|
|
1600
|
+
claim: `${label} loads ${f.words} words (~${f.approxTokens} tokens) into every session`,
|
|
1601
|
+
evidence: [`${label}: ${f.words} words`],
|
|
1535
1602
|
why: "Reference material that loads every session taxes attention and cost on tasks that never need it.",
|
|
1536
1603
|
action: "Extract the reference bulk into an on-demand skill/doc and keep a pointer.",
|
|
1537
1604
|
effort: "M",
|
|
@@ -1544,7 +1611,7 @@ var contextEconomyLens = {
|
|
|
1544
1611
|
lens: LENS_ID,
|
|
1545
1612
|
tier: "gap",
|
|
1546
1613
|
claim: `The always-loaded footprint is ${budget.totalWords} words (~${budget.totalApproxTokens} tokens) \u2014 over the ${budgets.totalWords}-word budget`,
|
|
1547
|
-
evidence: budget.files.slice(0, 5).map((f) => `${f
|
|
1614
|
+
evidence: budget.files.slice(0, 5).map((f) => `${contextFileLabel(f)}: ${f.words}w`),
|
|
1548
1615
|
why: "Every session pays this before the task begins; instruction-following degrades as the resident context grows.",
|
|
1549
1616
|
action: "Run `etymd context` for the per-file breakdown and extract the heaviest block.",
|
|
1550
1617
|
effort: "M",
|
|
@@ -1559,6 +1626,9 @@ var contextEconomyLens = {
|
|
|
1559
1626
|
status: "ran",
|
|
1560
1627
|
disclosures: [
|
|
1561
1628
|
...ctx.config?.problems ?? [],
|
|
1629
|
+
...aliased.map(
|
|
1630
|
+
(f) => `${contextFileLabel(f)} are one file (same inode) \u2014 counted once, at ${f.words} words.`
|
|
1631
|
+
),
|
|
1562
1632
|
`Budgets: ${budgets.perFileWords} words/file, ${budgets.totalWords} words total (${budgets.perFileWords === DEFAULT_CONFIG.context.perFileWords && budgets.totalWords === DEFAULT_CONFIG.context.totalWords ? `defaults \u2014 override under \`context\` in ${CONFIG_FILE}` : `set in ${CONFIG_FILE}`}). Scoped Cursor rules excluded.`
|
|
1563
1633
|
],
|
|
1564
1634
|
findings
|
|
@@ -1966,6 +2036,73 @@ async function buildGateInventory(root, facts) {
|
|
|
1966
2036
|
};
|
|
1967
2037
|
}
|
|
1968
2038
|
|
|
2039
|
+
// src/lenses/gate-integrity/screener.ts
|
|
2040
|
+
init_util();
|
|
2041
|
+
var pExecFile2 = promisify(execFile);
|
|
2042
|
+
var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
|
|
2043
|
+
var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
|
|
2044
|
+
var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
|
|
2045
|
+
async function probeScreener(root, facts) {
|
|
2046
|
+
const absent = {
|
|
2047
|
+
present: false,
|
|
2048
|
+
doors: [],
|
|
2049
|
+
runner: null,
|
|
2050
|
+
source: null,
|
|
2051
|
+
answersScreen: null
|
|
2052
|
+
};
|
|
2053
|
+
const dir = facts.hooks.dir;
|
|
2054
|
+
if (!dir) return absent;
|
|
2055
|
+
const doors = [];
|
|
2056
|
+
let devBuildArm = false;
|
|
2057
|
+
for (const name of HOOK_FILES) {
|
|
2058
|
+
const text = await readText(path2.join(root, dir, name));
|
|
2059
|
+
if (!text || !SCREEN_CALL_RE.test(text)) continue;
|
|
2060
|
+
doors.push(`${dir}/${name}`);
|
|
2061
|
+
if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
|
|
2062
|
+
}
|
|
2063
|
+
if (!doors.length) return absent;
|
|
2064
|
+
let runner = null;
|
|
2065
|
+
let source = null;
|
|
2066
|
+
const override = process.env.CONTENT_GATE;
|
|
2067
|
+
if (override) {
|
|
2068
|
+
runner = override;
|
|
2069
|
+
source = "CONTENT_GATE";
|
|
2070
|
+
} else if (devBuildArm && await pathExists(path2.join(root, "dist", "cli.js"))) {
|
|
2071
|
+
runner = path2.join(root, "dist", "cli.js");
|
|
2072
|
+
source = "dev-build";
|
|
2073
|
+
} else {
|
|
2074
|
+
runner = "etymd";
|
|
2075
|
+
source = "path";
|
|
2076
|
+
}
|
|
2077
|
+
try {
|
|
2078
|
+
await pExecFile2(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
|
|
2079
|
+
return { present: true, doors, runner, source, answersScreen: true };
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
const e = err;
|
|
2082
|
+
if (e.code === "ENOENT") {
|
|
2083
|
+
return {
|
|
2084
|
+
present: true,
|
|
2085
|
+
doors,
|
|
2086
|
+
runner: null,
|
|
2087
|
+
source,
|
|
2088
|
+
answersScreen: null,
|
|
2089
|
+
skipped: `no checker named \`${runner}\` is installed \u2014 the screen doors no-op here by design, and nothing was verified.`
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
if (e.killed || e.code === "ETIMEDOUT") {
|
|
2093
|
+
return {
|
|
2094
|
+
present: true,
|
|
2095
|
+
doors,
|
|
2096
|
+
runner,
|
|
2097
|
+
source,
|
|
2098
|
+
answersScreen: null,
|
|
2099
|
+
skipped: `\`${runner} screen --help\` did not return within 10s \u2014 the runner was not verified either way.`
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
return { present: true, doors, runner, source, answersScreen: false };
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1969
2106
|
// src/lenses/gate-integrity/lens.ts
|
|
1970
2107
|
var LENS_ID2 = "gate-integrity";
|
|
1971
2108
|
var CORRECTNESS_TOOLS = ["typecheck", "lint", "format-check", "test"];
|
|
@@ -2108,6 +2245,28 @@ function deriveGateFindings(inv, disclosures = []) {
|
|
|
2108
2245
|
function localToolInHook(inv, tool) {
|
|
2109
2246
|
return inv.local.preCommit.includes(tool) || inv.local.prePush.includes(tool) || inv.local.commitMsg.includes(tool);
|
|
2110
2247
|
}
|
|
2248
|
+
function deriveScreenerFindings(probe, disclosures) {
|
|
2249
|
+
if (!probe.present) return [];
|
|
2250
|
+
if (probe.skipped) {
|
|
2251
|
+
disclosures.push(`Content screen: ${probe.skipped}`);
|
|
2252
|
+
return [];
|
|
2253
|
+
}
|
|
2254
|
+
if (probe.answersScreen !== false) return [];
|
|
2255
|
+
const via = probe.source === "CONTENT_GATE" ? "resolved from the CONTENT_GATE override" : probe.source === "dev-build" ? "resolved to this repo's own ./dist/cli.js build" : "resolved from PATH";
|
|
2256
|
+
return [
|
|
2257
|
+
finding({
|
|
2258
|
+
id: `${LENS_ID2}/content-screen-unrunnable`,
|
|
2259
|
+
tier: "risk",
|
|
2260
|
+
kind: "truth",
|
|
2261
|
+
claim: `The content screen resolves to \`${probe.runner}\`, which does not understand \`screen\` \u2014 the gate cannot run`,
|
|
2262
|
+
evidence: [...probe.doors, `${probe.runner} screen --help \u2192 failed (${via})`],
|
|
2263
|
+
why: "The commit door fails closed on an error that explains nothing, and the push door \u2014 advisory by design \u2014 skips the whole-tree pass in silence, so the repo reads as screened when nothing screened it.",
|
|
2264
|
+
action: "Install or upgrade etymd (`screen` needs 0.11+), or point CONTENT_GATE at a checker that provides it, then re-run `etymd gates` to refresh the hooks.",
|
|
2265
|
+
effort: "S",
|
|
2266
|
+
confidence: "high"
|
|
2267
|
+
})
|
|
2268
|
+
];
|
|
2269
|
+
}
|
|
2111
2270
|
var gateIntegrityLens = {
|
|
2112
2271
|
id: LENS_ID2,
|
|
2113
2272
|
version: "1",
|
|
@@ -2148,6 +2307,7 @@ var gateIntegrityLens = {
|
|
|
2148
2307
|
if (inv.ci.system === "none")
|
|
2149
2308
|
disclosures.push("No CI configuration found \u2014 CI\u2194local comparisons skipped.");
|
|
2150
2309
|
const findings = deriveGateFindings(inv, disclosures);
|
|
2310
|
+
findings.push(...deriveScreenerFindings(await probeScreener(ctx.root, ctx.facts), disclosures));
|
|
2151
2311
|
return {
|
|
2152
2312
|
lens: LENS_ID2,
|
|
2153
2313
|
version: "1",
|
|
@@ -2692,8 +2852,23 @@ var KNOWN_DOC_REFS = [
|
|
|
2692
2852
|
"DECISIONS.md",
|
|
2693
2853
|
"GEMINI.md"
|
|
2694
2854
|
];
|
|
2855
|
+
var PATH_TOKEN_CHARS = /[A-Za-z0-9_.$~/-]/;
|
|
2695
2856
|
function extractDocRefs(text) {
|
|
2696
|
-
|
|
2857
|
+
const refs = [];
|
|
2858
|
+
let tildeSkipped = 0;
|
|
2859
|
+
for (const name of KNOWN_DOC_REFS) {
|
|
2860
|
+
let claimed = false;
|
|
2861
|
+
let at = text.indexOf(name);
|
|
2862
|
+
while (at !== -1) {
|
|
2863
|
+
let head = at;
|
|
2864
|
+
while (head > 0 && PATH_TOKEN_CHARS.test(text[head - 1])) head -= 1;
|
|
2865
|
+
if (text[head] === "~") tildeSkipped += 1;
|
|
2866
|
+
else claimed = true;
|
|
2867
|
+
at = text.indexOf(name, at + name.length);
|
|
2868
|
+
}
|
|
2869
|
+
if (claimed) refs.push(name);
|
|
2870
|
+
}
|
|
2871
|
+
return { refs, tildeSkipped };
|
|
2697
2872
|
}
|
|
2698
2873
|
|
|
2699
2874
|
// src/lenses/instruction-truth/lens.ts
|
|
@@ -2816,6 +2991,7 @@ var instructionTruthLens = {
|
|
|
2816
2991
|
const nodeModulesInstalled = await pathExists(path2.join(root, "node_modules"));
|
|
2817
2992
|
const manifestExists = await pathExists(path2.join(root, "package.json")) || facts.packages.length > 0;
|
|
2818
2993
|
let totalFilteredSkipped = 0;
|
|
2994
|
+
let totalTildeSkipped = 0;
|
|
2819
2995
|
let binaryResolved = 0;
|
|
2820
2996
|
let unverifiableCommands = 0;
|
|
2821
2997
|
let gitignoredSkipped = 0;
|
|
@@ -2905,7 +3081,9 @@ var instructionTruthLens = {
|
|
|
2905
3081
|
break;
|
|
2906
3082
|
}
|
|
2907
3083
|
}
|
|
2908
|
-
|
|
3084
|
+
const { refs: docRefs, tildeSkipped } = extractDocRefs(file.text);
|
|
3085
|
+
totalTildeSkipped += tildeSkipped;
|
|
3086
|
+
for (const ref of docRefs) {
|
|
2909
3087
|
if (await pathExists(path2.join(root, ref))) continue;
|
|
2910
3088
|
findings.push(
|
|
2911
3089
|
finding2({
|
|
@@ -3010,6 +3188,11 @@ var instructionTruthLens = {
|
|
|
3010
3188
|
`${placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
3011
3189
|
);
|
|
3012
3190
|
}
|
|
3191
|
+
if (totalTildeSkipped) {
|
|
3192
|
+
disclosures.push(
|
|
3193
|
+
`${totalTildeSkipped} well-known doc mention(s) sit inside \`~/\` home paths (e.g. \`~/.claude/CLAUDE.md\`) \u2014 machine-global files, not this repo's; skipped, not flagged.`
|
|
3194
|
+
);
|
|
3195
|
+
}
|
|
3013
3196
|
if (stateDocs.length) {
|
|
3014
3197
|
disclosures.push(
|
|
3015
3198
|
`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"}.`
|
|
@@ -3037,7 +3220,7 @@ var instructionTruthLens = {
|
|
|
3037
3220
|
);
|
|
3038
3221
|
}
|
|
3039
3222
|
disclosures.push(
|
|
3040
|
-
`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.`
|
|
3223
|
+
`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; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
|
|
3041
3224
|
);
|
|
3042
3225
|
return {
|
|
3043
3226
|
lens: LENS_ID4,
|
|
@@ -3470,7 +3653,7 @@ async function loadFleetManifest(manifestPath) {
|
|
|
3470
3653
|
init_config();
|
|
3471
3654
|
init_facts();
|
|
3472
3655
|
init_util();
|
|
3473
|
-
var
|
|
3656
|
+
var pExecFile3 = promisify(execFile);
|
|
3474
3657
|
var FLEET_LENS = "fleet-manifest";
|
|
3475
3658
|
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
3476
3659
|
var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
@@ -3525,7 +3708,7 @@ function stateBudgetsFor(entry) {
|
|
|
3525
3708
|
async function gitGrepFiles(root, needle, mode = "fixed") {
|
|
3526
3709
|
try {
|
|
3527
3710
|
const matchFlag = mode === "regex" ? "-E" : "-F";
|
|
3528
|
-
const { stdout } = await
|
|
3711
|
+
const { stdout } = await pExecFile3("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
|
|
3529
3712
|
cwd: root,
|
|
3530
3713
|
timeout: 8e3
|
|
3531
3714
|
});
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { applyFiles } from './chunk-GWGKEPRX.js';
|
|
3
|
-
import { planWorkflow } from './chunk-
|
|
4
|
-
import './chunk-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { PACK_VERSION } from './chunk-LSZGCKIQ.js';
|
|
3
|
+
import { planWorkflow } from './chunk-UFNETE6P.js';
|
|
4
|
+
import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
|
|
5
|
+
import { scanProject } from './chunk-HWC6PKJM.js';
|
|
6
|
+
import { VERSION } from './chunk-623W6KVC.js';
|
|
7
|
+
import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
|
|
8
|
+
import { PACK_VERSION } from './chunk-YQZDYDAK.js';
|
|
10
9
|
import { git, readText } from './chunk-4VPBP6K6.js';
|
|
11
10
|
import { promises } from 'fs';
|
|
12
11
|
import path from 'path';
|
|
@@ -57,12 +56,14 @@ async function run(opts) {
|
|
|
57
56
|
const hasContract = facts.artifacts.some((a) => a.id === "agents" && a.exists);
|
|
58
57
|
let scaffoldAgents = false;
|
|
59
58
|
if (!hasContract) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
if (opts.withAgents) scaffoldAgents = true;
|
|
60
|
+
else if (!opts.yes)
|
|
61
|
+
scaffoldAgents = guard(
|
|
62
|
+
await confirm({
|
|
63
|
+
message: "No AGENTS.md found \u2014 scaffold a minimal one from the reckoning?",
|
|
64
|
+
initialValue: true
|
|
65
|
+
})
|
|
66
|
+
);
|
|
66
67
|
}
|
|
67
68
|
let gates = false;
|
|
68
69
|
if (facts.git.isRepo && facts.hooks.source === "none") {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { print, renderFacts, theme } from './chunk-
|
|
3
|
-
import { scanProject } from './chunk-
|
|
4
|
-
import './chunk-
|
|
5
|
-
import { writeCachedFacts } from './chunk-
|
|
6
|
-
import './chunk-
|
|
2
|
+
import { print, renderFacts, theme } from './chunk-HI7NWPRA.js';
|
|
3
|
+
import { scanProject } from './chunk-HWC6PKJM.js';
|
|
4
|
+
import './chunk-623W6KVC.js';
|
|
5
|
+
import { writeCachedFacts } from './chunk-P6ATKV2R.js';
|
|
6
|
+
import './chunk-YQZDYDAK.js';
|
|
7
7
|
import './chunk-4VPBP6K6.js';
|
|
8
8
|
|
|
9
9
|
// src/commands/scan.ts
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { print, glyph, theme, section } from './chunk-
|
|
2
|
+
import { print, glyph, theme, section } from './chunk-HI7NWPRA.js';
|
|
3
|
+
import './chunk-P6ATKV2R.js';
|
|
3
4
|
import { git, readText, pathExists } from './chunk-4VPBP6K6.js';
|
|
4
5
|
import { promises } from 'fs';
|
|
5
6
|
import path from 'path';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "etymd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
package/dist/audit-YRT4SWSQ.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
export { run } from './chunk-DSAQ5S5D.js';
|
|
3
|
-
import './chunk-PXRLOEN5.js';
|
|
4
|
-
import './chunk-F75Q43BC.js';
|
|
5
|
-
import './chunk-3E2IPCRY.js';
|
|
6
|
-
import './chunk-DWL2IKZH.js';
|
|
7
|
-
import './chunk-5BVKFJWM.js';
|
|
8
|
-
import './chunk-YXOAPMQH.js';
|
|
9
|
-
import './chunk-D3R74TJ2.js';
|
|
10
|
-
import './chunk-JHZ2BN4U.js';
|
|
11
|
-
import './chunk-LSZGCKIQ.js';
|
|
12
|
-
import './chunk-4VPBP6K6.js';
|
package/dist/chunk-F75Q43BC.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { DEFAULT_CONFIG } from './chunk-DWL2IKZH.js';
|
|
3
|
-
import { readText, wordCount, approxTokens, pathExists } from './chunk-4VPBP6K6.js';
|
|
4
|
-
import { promises } from 'fs';
|
|
5
|
-
import path from 'path';
|
|
6
|
-
|
|
7
|
-
var ALWAYS_LOADED = [
|
|
8
|
-
{ path: "AGENTS.md", role: "operating contract" },
|
|
9
|
-
{ path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
|
|
10
|
-
{ path: "CLAUDE.md", role: "Claude Code pointer" },
|
|
11
|
-
{ path: "GEMINI.md", role: "Gemini pointer" },
|
|
12
|
-
{ path: ".github/copilot-instructions.md", role: "Copilot instructions" },
|
|
13
|
-
{ path: ".cursorrules", role: "Cursor rules (legacy)" }
|
|
14
|
-
];
|
|
15
|
-
var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
|
|
16
|
-
function isAlwaysAppliedCursorRule(text) {
|
|
17
|
-
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
18
|
-
if (!fm) return true;
|
|
19
|
-
const frontmatter = fm[1] ?? "";
|
|
20
|
-
return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
|
|
21
|
-
}
|
|
22
|
-
async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
23
|
-
const files = [];
|
|
24
|
-
for (const spec of ALWAYS_LOADED) {
|
|
25
|
-
const abs = path.join(root, spec.path);
|
|
26
|
-
const text = await readText(abs);
|
|
27
|
-
if (text === null) continue;
|
|
28
|
-
const words = wordCount(text);
|
|
29
|
-
files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
|
|
30
|
-
}
|
|
31
|
-
const rulesDir = path.join(root, ".cursor", "rules");
|
|
32
|
-
if (await pathExists(rulesDir)) {
|
|
33
|
-
try {
|
|
34
|
-
for (const entry of await promises.readdir(rulesDir)) {
|
|
35
|
-
if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
|
|
36
|
-
const text = await readText(path.join(rulesDir, entry));
|
|
37
|
-
if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
|
|
38
|
-
const words = wordCount(text);
|
|
39
|
-
files.push({
|
|
40
|
-
path: `.cursor/rules/${entry}`,
|
|
41
|
-
role: "Cursor rule (always applied)",
|
|
42
|
-
words,
|
|
43
|
-
approxTokens: approxTokens(words)
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
const totalWords = files.reduce((s, f) => s + f.words, 0);
|
|
50
|
-
return {
|
|
51
|
-
files: files.sort((a, b) => b.words - a.words),
|
|
52
|
-
totalWords,
|
|
53
|
-
totalApproxTokens: approxTokens(totalWords),
|
|
54
|
-
perFileWords,
|
|
55
|
-
extractionCandidates: files.filter((f) => f.words >= perFileWords)
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export { measureContext };
|