agent-quality-kit 0.7.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.
Files changed (43) hide show
  1. package/README.md +45 -2
  2. package/README.ru.md +45 -2
  3. package/kit/gates/_skip.sh +61 -1
  4. package/kit/gates/ci-not-hijackable/README.md +56 -0
  5. package/kit/gates/ci-not-hijackable/check.sh +73 -0
  6. package/kit/gates/ci-not-hijackable/gate.yml +19 -0
  7. package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
  8. package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
  9. package/kit/gates/color-from-token/check.sh +6 -2
  10. package/kit/gates/color-from-token/green/Button.tsx +2 -0
  11. package/kit/gates/complexity-limit/check.sh +6 -7
  12. package/kit/gates/duplicate-code/check.sh +5 -1
  13. package/kit/gates/entry-links-exist/check.sh +4 -1
  14. package/kit/gates/entry-links-exist/green/AGENTS.md +2 -0
  15. package/kit/gates/file-size-limit/check.sh +1 -1
  16. package/kit/gates/secrets-not-in-code/check.sh +16 -3
  17. package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
  18. package/kit/gates/todo-without-task/check.sh +1 -1
  19. package/kit/gates/todo-without-task/green/app.py +1 -0
  20. package/llms.txt +22 -1
  21. package/package.json +4 -1
  22. package/tool/commands/context.mjs +260 -0
  23. package/tool/commands/doctor.mjs +25 -18
  24. package/tool/commands/learn.mjs +159 -0
  25. package/tool/commands/project.mjs +1 -0
  26. package/tool/commands/report.mjs +33 -1
  27. package/tool/i18n/en-docs.mjs +85 -1
  28. package/tool/i18n/en.mjs +21 -36
  29. package/tool/i18n/ru-docs.mjs +87 -1
  30. package/tool/i18n/ru.mjs +21 -36
  31. package/tool/lib/core.mjs +30 -1
  32. package/tool/lib/evidence.mjs +124 -0
  33. package/tool/lib/manifest.mjs +29 -2
  34. package/tool/lib/prove.mjs +13 -1
  35. package/tool/lib/scope.mjs +10 -1
  36. package/tool/lib/templates.mjs +1 -0
  37. package/tool/program.mjs +19 -23
  38. package/tool/selfcheck/smoke.sh +242 -2
  39. package/tool/selfcheck/units-context.mjs +186 -0
  40. package/tool/selfcheck/units-evidence.mjs +83 -0
  41. package/tool/selfcheck/units-learn.mjs +88 -0
  42. package/tool/selfcheck/units-level.mjs +65 -3
  43. package/tool/selfcheck/units.mjs +1 -0
@@ -33,6 +33,15 @@ const ANSI = new RegExp(String.fromCharCode(27) + "\\[[0-9;]*[a-zA-Z]", "g");
33
33
  // принималась бы за находку и отбрасывалась.
34
34
  const CANDIDATE = /[\w.@+-]+(?:\/[\w.@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}/g;
35
35
 
36
+ // Все пути, названные в тексте. Тот же разбор, что при сужении: цвет снимается, вид пути
37
+ // нормализуется. Вынесено отдельно, потому что покрытие спрашивает у вывода обратное:
38
+ // не «попадает ли находка в диф», а «назвал ли гейт этот файл».
39
+ function pathsIn(text) {
40
+ const out = new Set();
41
+ for (const m of String(text).replace(ANSI, "").matchAll(CANDIDATE)) out.add(normPath(m[0]));
42
+ return out;
43
+ }
44
+
36
45
  function inScope(candidate, files) {
37
46
  const c = normPath(candidate);
38
47
  if (files.has(c)) return true;
@@ -128,4 +137,4 @@ function changedFiles(ref, cwd) {
128
137
  );
129
138
  }
130
139
 
131
- export { scopeOutput, splitAdvice, changedFiles };
140
+ export { scopeOutput, splitAdvice, changedFiles, pathsIn, normPath };
@@ -28,6 +28,7 @@ const MANIFEST_YML = [
28
28
  "",
29
29
  d.rules,
30
30
  "rules: .aqk/rules",
31
+ "docs: .aqk/docs",
31
32
  "gates:",
32
33
  ...d.gates,
33
34
  "",
package/tool/program.mjs CHANGED
@@ -16,14 +16,16 @@
16
16
 
17
17
  import { realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
- import { c, SELF } from "./lib/core.mjs";
19
+ import { c, SELF, commandRows } from "./lib/core.mjs";
20
20
  import { L } from "./i18n/index.mjs";
21
21
  import { cmdInit, cmdNote, cmdBlob, cmdStart } from "./commands/project.mjs";
22
22
  import { cmdDoctor } from "./commands/doctor.mjs";
23
23
  import { cmdAdd, cmdNew, cmdRatchet, cmdFind, cmdWhy } from "./commands/gates.mjs";
24
24
  import { cmdReport } from "./commands/report.mjs";
25
+ import { cmdLearn } from "./commands/learn.mjs";
25
26
  import { cmdBadge } from "./commands/badge.mjs";
26
27
  import { cmdProve } from "./commands/prove.mjs";
28
+ import { cmdContext } from "./commands/context.mjs";
27
29
 
28
30
  // Разбор аргументов выполняется только при запуске файла как программы. При импорте —
29
31
  // а так его читают модульные проверки tool/selfcheck/units.mjs — CLI запускаться не должен.
@@ -67,46 +69,40 @@ if (IS_MAIN) {
67
69
  case "blob":
68
70
  await cmdBlob();
69
71
  break;
72
+ // Читает локальную переписку — поэтому только в терминал и всегда с кодом 0. Подробности
73
+ // и замер, на котором стоит отбор, — в шапке tool/commands/learn.mjs.
74
+ case "learn":
75
+ await cmdLearn();
76
+ break;
77
+
70
78
  case "report":
71
79
  await cmdReport();
72
80
  break;
73
81
  case "prove":
74
82
  await cmdProve();
75
83
  break;
84
+ // Печатает состояние репозитория для КОНТЕКСТА агента, а не для человека. Зовётся хуком
85
+ // SessionStart, поэтому ничего не запускает и всегда выходит с нулём: хук, роняющий запуск
86
+ // агента из-за неготового проекта, отключат в тот же день, и не станет ни хука, ни блока.
87
+ case "context":
88
+ await cmdContext(rest);
89
+ break;
76
90
  case "badge":
77
91
  await cmdBadge(rest);
78
92
  break;
79
93
  default: {
80
94
  // Ширина колонки считается, а не подбирается пробелами: строки в двух языках разной
81
95
  // длины, и вручную выровненная справка на втором языке разъезжается.
82
- const h = L.help;
83
- const rows = [
84
- [`${SELF} init`, h.init],
85
- [`${SELF} init --force`, h.initForce],
86
- [`${SELF} start`, h.start],
87
- [`${SELF} doctor`, h.doctor],
88
- [`${SELF} doctor --run`, h.doctorRun],
89
- [`${SELF} doctor --run --since main`, h.doctorSince],
90
- [`${SELF} prove`, h.prove],
91
- [`${SELF} add ${h.name}`, h.add],
92
- [`${SELF} find "…"`, h.find],
93
- [`${SELF} why "…"`, h.why],
94
- [`${SELF} ratchet ${h.name}`, h.ratchet],
95
- [`${SELF} new ${h.name}`, h.new],
96
- [`${SELF} note "…"`, h.note],
97
- [`${SELF} blob`, h.blob],
98
- [`${SELF} report`, h.report],
99
- [`${SELF} badge`, h.badge],
100
- ];
96
+ const rows = commandRows(L).map((r) => [`${SELF} ${r.name}${r.args ? " " + r.args : ""}`, r.text]);
101
97
  const width = Math.max(...rows.map(([cmdText]) => cmdText.length));
102
98
  const lines = rows.map(([cmdText, text]) => ` ${c.bold(cmdText.padEnd(width))} ${text}`);
103
99
  console.log(`
104
- ${c.bold("aqk")} — ${h.tagline}
100
+ ${c.bold("aqk")} — ${L.help.tagline}
105
101
 
106
102
  ${lines.join("\n")}
107
103
 
108
- ${c.dim(h.noInstall)}
109
- ${c.dim(h.language)}
104
+ ${c.dim(L.help.noInstall)}
105
+ ${c.dim(L.help.language)}
110
106
  `);
111
107
  process.exit(cmd ? 1 : 0);
112
108
  }
@@ -25,6 +25,13 @@ FAIL=0
25
25
  ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; PASS=$((PASS + 1)); }
26
26
  bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; printf ' %s\n' "${2:-}"; FAIL=$((FAIL + 1)); }
27
27
 
28
+ # Node на Windows видит мир глазами Windows, а Git Bash — глазами POSIX: путь вида
29
+ # /tmp/tmp.XXXX, отданный в `node -e`, там не существует, и проверка падает не на том, что
30
+ # проверяет. Поймано дважды на windows-прогоне — сперва на `learn`, потом на установке хука.
31
+ # Поэтому node запускается ИЗ каталога и получает относительный путь: помощник, а не памятка,
32
+ # потому что памятку третий раз забудут ровно так же, как забыли второй.
33
+ node_in() { D="$1"; shift; ( cd "$D" && node "$@" ); }
34
+
28
35
  WORK="$(mktemp -d)"
29
36
  trap 'rm -rf "$WORK"' EXIT
30
37
 
@@ -1183,7 +1190,11 @@ rm -rf "$VDIR"
1183
1190
  # v0.4.2, отставание на два выпуска. Проверка сравнивает обе с package.json, а не друг с другом:
1184
1191
  # совпасть друг с другом они могут и будучи одинаково устаревшими.
1185
1192
  VERS_BAD=""
1186
- for VF in README.md llms.txt; do
1193
+ # README.ru.md попал сюда 2026-09-08, при выпуске 0.8.0: в нём стояли v0.6.0 и v0.5.0 —
1194
+ # отставание на два и на три выпуска. Проверка его не смотрела, и русский читатель ставил
1195
+ # прошлогодний комплект молча. Ровно та же дыра, ради которой проверку и заводили, — просто
1196
+ # в файле, который забыли перечислить.
1197
+ for VF in README.md README.ru.md llms.txt; do
1187
1198
  [ -f "$ROOT/$VF" ] || continue
1188
1199
  # Берём только версии AQK — «v1.2.3» в примерах чужих действий (actions/checkout@v4) не наши.
1189
1200
  for V in $(grep -oE '(rev:[[:space:]]*|Agent_Quality_Kit@)v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/$VF" \
@@ -1192,11 +1203,240 @@ for VF in README.md llms.txt; do
1192
1203
  done
1193
1204
  done
1194
1205
  if [ -z "$VERS_BAD" ]; then
1195
- ok "версия в README и llms.txt совпадает с package.json (v$PKGVER)"
1206
+ ok "версия в обоих README и llms.txt совпадает с package.json (v$PKGVER)"
1196
1207
  else
1197
1208
  bad "версия в документах разошлась с package.json" "package.json: v$PKGVER; найдено — $VERS_BAD"
1198
1209
  fi
1199
1210
 
1211
+ # --- 82. report --since называет, чем доказан диф ---------------------------
1212
+ # Три состояния у файла, и разница между вторым и третьим — весь смысл раздела: «проверка
1213
+ # обошла и промолчала» не то же самое, что «никто не смотрел». Двух состояний хватило ровно до
1214
+ # первого прогона — `tool/commands/doctor.mjs` попал в «никем не проверен», хотя его обходят
1215
+ # пять проверок; они молчали, потому что нашли чисто.
1216
+ EVDIR="$(mktemp -d)"
1217
+ (
1218
+ cd "$EVDIR" || exit 1
1219
+ git init -q . && git config user.email a@b.c && git config user.name t
1220
+ mkdir -p src gates/noisy
1221
+ printf 'x = 1\n' > src/kept.py
1222
+ # Гейт, который печатает путь: по нему файл становится «назван».
1223
+ printf '#!/usr/bin/env sh\necho "src/kept.py:1: нашёл"\nexit 0\n' > gates/noisy/check.sh
1224
+ printf 'aqk: "1"\nentry: [AGENTS.md]\ngates:\n noisy: "sh gates/noisy/check.sh ."\n' > .aqk.yml
1225
+ printf '# правила\n' > AGENTS.md
1226
+ git add -A >/dev/null 2>&1 && git commit -qm base >/dev/null 2>&1
1227
+ printf 'y = 2\n' > src/quiet.py
1228
+ git add -A >/dev/null 2>&1 && git commit -qm second >/dev/null 2>&1
1229
+ ) >/dev/null 2>&1
1230
+ EV_OUT=$( cd "$EVDIR" && AQK_LANG=ru node "$CLI" report --since HEAD~1 2>&1 )
1231
+ EV_FILE="$EVDIR/.aqk/report.md"
1232
+ if [ -f "$EV_FILE" ] && grep -q "src/quiet.py" "$EV_FILE" && grep -q "Чем доказан" "$EV_FILE"; then
1233
+ ok "report --since называет, чем доказан диф"
1234
+ else
1235
+ bad "report --since не отчитался о покрытии" "$(printf '%s' "$EV_OUT" | tail -2 | head -1)"
1236
+ fi
1237
+ # Ссылка, которой нет, обязана быть названа: «сравнили не с тем» не должно читаться как «чисто».
1238
+ EV_BAD=$( cd "$EVDIR" && AQK_LANG=ru node "$CLI" report --since no-such-ref 2>&1 )
1239
+ if printf '%s' "$EV_BAD" | grep -q "no-such-ref"; then
1240
+ ok "report --since называет неразобранную ссылку"
1241
+ else
1242
+ bad "report --since проглотил неверную ссылку" "$(printf '%s' "$EV_BAD" | tail -2 | head -1)"
1243
+ fi
1244
+ rm -rf "$EVDIR"
1245
+
1246
+ # --- 84. learn читает только напечатанное человеком --------------------------
1247
+ # Поле promptSource отделяет реплику от результата инструмента. Без него первая версия отбора
1248
+ # выдавала вставленные пути и ссылки вместо правил — «agent quality kit» 44 раза.
1249
+ # Каталог логов кладём ВНУТРЬ проекта и передаём относительным путём. Абсолютный сюда не
1250
+ # годится: Git Bash на Windows отдаёт «/tmp/…», а Node в том же окружении читает это как
1251
+ # «C:\tmp\…» — переменная указывает в никуда, и проверка падает не по делу.
1252
+ LRNP="$(mktemp -d)"; LRN=".cfg"
1253
+ # Имя каталога логов спрашиваем у самой программы, а не считаем здесь. На Windows оболочка
1254
+ # отдаёт «/tmp/…», а Node видит «C:\Users\…» — две разные строки, и тест падал не по делу.
1255
+ # Правило перевода пути в имя сторожит модульная проверка logSlug, здесь проверяется конвейер.
1256
+ SLUG=$( cd "$LRNP" && node -e "const {pathToFileURL}=require('node:url');import(pathToFileURL(process.argv[1]).href).then(m=>console.log(m.logSlug(process.cwd())))" "$ROOT/tool/commands/learn.mjs" )
1257
+ mkdir -p "$LRNP/$LRN/projects/$SLUG"
1258
+ {
1259
+ printf '{"type":"user","promptSource":"typed","timestamp":"2026-09-08T10:00:00Z","message":{"role":"user","content":"никогда не коммить прямо в основную ветку"}}\n'
1260
+ printf '{"type":"user","promptSource":"typed","timestamp":"2026-09-08T10:01:00Z","message":{"role":"user","content":"ок го дальше"}}\n'
1261
+ printf '{"type":"user","timestamp":"2026-09-08T10:02:00Z","message":{"role":"user","content":[{"type":"tool_result","content":"нельзя обязательно всегда"}]}}\n'
1262
+ } > "$LRNP/$LRN/projects/$SLUG/s1.jsonl"
1263
+ printf '# правила\n- Ничего особенного.\n' > "$LRNP/AGENTS.md"
1264
+ printf 'aqk: "1"\nentry: [AGENTS.md]\n' > "$LRNP/.aqk.yml"
1265
+ LRN_OUT=$( cd "$LRNP" && CLAUDE_CONFIG_DIR="$LRN" AQK_LANG=ru node "$CLI" learn 2>&1 )
1266
+ if printf '%s' "$LRN_OUT" | grep -q "основную ветку" &&
1267
+ ! printf '%s' "$LRN_OUT" | grep -q "го дальше" &&
1268
+ printf '%s' "$LRN_OUT" | grep -q "напечатано человеком: 2"; then
1269
+ ok "learn берёт напечатанное человеком и не берёт вывод инструментов"
1270
+ else
1271
+ bad "learn отобрал не то" "$(printf '%s' "$LRN_OUT" | tr '\n' ' ' | cut -c1-150)"
1272
+ fi
1273
+ # Правило, уже стоящее в точке входа, показывать незачем: команда не пересказывает свод.
1274
+ printf '# правила\n- Никогда не коммить прямо в основную ветку.\n' > "$LRNP/AGENTS.md"
1275
+ LRN_W=$( cd "$LRNP" && CLAUDE_CONFIG_DIR="$LRN" AQK_LANG=ru node "$CLI" learn 2>&1 )
1276
+ if printf '%s' "$LRN_W" | grep -q "уже стоит в точке входа"; then
1277
+ ok "learn молчит о правиле, которое уже записано"
1278
+ else
1279
+ bad "learn повторил записанное правило" "$(printf '%s' "$LRN_W" | tr '\n' ' ' | cut -c1-150)"
1280
+ fi
1281
+ # Читает переписку — значит на диск не пишет ничего. Проверяем буквально.
1282
+ if [ ! -d "$LRNP/.aqk" ] || [ -z "$(ls -A "$LRNP/.aqk" 2>/dev/null)" ]; then
1283
+ ok "learn ничего не записал на диск"
1284
+ else
1285
+ bad "learn создал файлы" "$(ls -A "$LRNP/.aqk" | tr '\n' ' ')"
1286
+ fi
1287
+ rm -rf "$LRNP"
1288
+
1289
+ # --- 87. --baseline не совмещается с --run и --min ---------------------------
1290
+ # `--baseline` выходит с нулём всегда: это осмотр, а не прогон. Совмещённый с порогом он давал
1291
+ # конвейер, который НЕ МОЖЕТ покраснеть — порог назван, гейты не запущены, код нулевой. Найдено
1292
+ # ревью 2026-09-08. Отказ должен быть громким: молчаливое зелёное здесь дороже сломанной команды.
1293
+ BLP="$(mktemp -d)"
1294
+ ( cd "$BLP" && git init -q . && printf 'aqk: "1"\nentry: [AGENTS.md]\n' > .aqk.yml && printf '# правила\n' > AGENTS.md ) >/dev/null 2>&1
1295
+ BL_OK=$( cd "$BLP" && AQK_LANG=ru node "$CLI" doctor --baseline 2>&1 ); BL_OK_C=$?
1296
+ BL_BAD=$( cd "$BLP" && AQK_LANG=ru node "$CLI" doctor --baseline --min 1 2>&1 ); BL_BAD_C=$?
1297
+ BL_RUN=$( cd "$BLP" && AQK_LANG=ru node "$CLI" doctor --baseline --run 2>&1 ); BL_RUN_C=$?
1298
+ if [ "$BL_OK_C" -eq 0 ] && [ "$BL_BAD_C" -ne 0 ] && [ "$BL_RUN_C" -ne 0 ] &&
1299
+ printf '%s' "$BL_BAD" | grep -q "не может покраснеть"; then
1300
+ ok "--baseline с --min и --run отказывает вслух, сам по себе работает"
1301
+ else
1302
+ bad "--baseline не отказал на пороге" "коды: сам $BL_OK_C, с --min $BL_BAD_C, с --run $BL_RUN_C"
1303
+ fi
1304
+ rm -rf "$BLP"
1305
+
1306
+ # --- 88. записи каталога не ломаются от цвета в выводе арбитра ---------------
1307
+ # Внутри GitHub Actions чужие инструменты КРАСЯТ вывод: там цвет поддержан, вне конвейера они
1308
+ # его выключают сами. Разбор по началу строки тогда промахивается — строка начинается с
1309
+ # escape-последовательности. Поймано конвейером 2026-09-08 на записи ci-not-hijackable: локально
1310
+ # зелёная, в конвейере «формат сменился». Тот же класс уже записан в scope.mjs.
1311
+ CLR_BAD=""
1312
+ for CG in kit/gates/*/check.sh; do
1313
+ CS=$(basename "$(dirname "$CG")")
1314
+ # Только записи с готовым арбитром: у них вывод чужой, и красит его не наш код.
1315
+ grep -q "^requires:" "$(dirname "$CG")/gate.yml" 2>/dev/null || continue
1316
+ command -v "$(sed -n 's/^requires:[[:space:]]*//p' "$(dirname "$CG")/gate.yml" | head -1 | awk '{print $1}')" >/dev/null 2>&1 || continue
1317
+ PLAIN=$(bash "$CG" "$(dirname "$CG")/green" >/dev/null 2>&1; echo $?)
1318
+ COLOR=$(GITHUB_ACTIONS=true CI=true bash "$CG" "$(dirname "$CG")/green" >/dev/null 2>&1; echo $?)
1319
+ [ "$PLAIN" = "$COLOR" ] || CLR_BAD="${CLR_BAD:+$CLR_BAD, }$CS ($PLAIN vs $COLOR)"
1320
+ done
1321
+ if [ -z "$CLR_BAD" ]; then
1322
+ ok "вердикт записи не зависит от того, красит ли арбитр вывод"
1323
+ else
1324
+ bad "цвет в выводе арбитра меняет вердикт" "$CLR_BAD"
1325
+ fi
1326
+
1327
+ # --- 89. шапка doctor читает манифест, а не литеральные пути ------------------
1328
+ # Отзыв второго пользователя 2026-09-08: у проекта `rules: .temper/rules`, правила на месте,
1329
+ # СТУПЕНЬ по манифесту берётся — а шапка рисовала кресты за `.aqk/rules` и `.aqk/docs` и
1330
+ # советовала сделать сделанное. Вывод расходился с собственным вердиктом программы, напечатанным
1331
+ # на десять строк ниже. Модульная проверка сторожит функцию; эта — то, что её кто-то зовёт.
1332
+ LYP="$(mktemp -d)"
1333
+ ( cd "$LYP" && git init -q . && mkdir -p .temper/rules .temper/docs &&
1334
+ printf 'правило\n' > .temper/rules/r.md && printf 'методичка\n' > .temper/docs/d.md &&
1335
+ printf '# вход\n' > CLAUDE.md && printf '.x\n' > .gitignore &&
1336
+ printf 'aqk: 1\nentry:\n - CLAUDE.md\nrules: .temper/rules\ndocs: .temper/docs\ngates:\n smoke: "true"\n' > .aqk.yml ) >/dev/null 2>&1
1337
+ LY=$( cd "$LYP" && AQK_LANG=ru node "$CLI" doctor 2>&1 )
1338
+ if printf '%s' "$LY" | grep -q "\.temper/rules" &&
1339
+ printf '%s' "$LY" | grep -q "\.temper/docs" &&
1340
+ printf '%s' "$LY" | grep -q "CLAUDE\.md" &&
1341
+ ! printf '%s' "$LY" | grep -q "\.aqk/rules" &&
1342
+ ! printf '%s' "$LY" | grep -q "неизвестное поле"; then
1343
+ ok "шапка doctor берёт правила, методички и вход из манифеста"
1344
+ else
1345
+ bad "doctor проверил не то, что объявлено в манифесте" "$(printf '%s' "$LY" | head -8)"
1346
+ fi
1347
+ rm -rf "$LYP"
1348
+
1349
+ # --- 90. блок состояния: тишина не выдаётся за «чисто» -----------------------
1350
+ # Читатель этого блока — машина. Человек, увидев пустое место, переспросит; агент примет его
1351
+ # за утверждение и пойдёт писать код по несуществующему разрешению. Поэтому главное здесь одно:
1352
+ # без прогона блок обязан сказать «неизвестно» СЛОВОМ.
1353
+ CTXP="$(mktemp -d)"
1354
+ ( cd "$CTXP" && git init -q . ) >/dev/null 2>&1
1355
+ CTX=$( cd "$CTXP" && AQK_LANG=ru node "$CLI" context 2>&1 ); CTX_C=$?
1356
+ if [ "$CTX_C" -eq 0 ] &&
1357
+ printf '%s' "$CTX" | grep -q "НЕИЗВЕСТНО" &&
1358
+ printf '%s' "$CTX" | grep -q "не вычислен" &&
1359
+ ! printf '%s' "$CTX" | grep -q "AGENTS.md"; then
1360
+ ok "context без прогона говорит «неизвестно» и не называет несуществующий свод"
1361
+ else
1362
+ bad "context выдал незнание за чистоту" "код $CTX_C: $(printf '%s' "$CTX" | head -5)"
1363
+ fi
1364
+
1365
+ # --- 91. хук ставится в общий файл и не затирает чужие настройки --------------
1366
+ mkdir -p "$CTXP/.claude"
1367
+ printf '{ "permissions": { "deny": ["Read(./.env)"] } }\n' > "$CTXP/.claude/settings.json"
1368
+ ( cd "$CTXP" && AQK_LANG=ru node "$CLI" context --install ) >/dev/null 2>&1
1369
+ AGAIN=$( cd "$CTXP" && AQK_LANG=ru node "$CLI" context --install 2>&1 )
1370
+ HOOKS=$(node_in "$CTXP" -e 'const s=require("./.claude/settings.json");
1371
+ console.log([s.hooks?.SessionStart?.length, s.permissions?.deny?.length,
1372
+ /[/\\]program\.mjs/.test(JSON.stringify(s.hooks?.SessionStart||[]))].join(" "))' 2>&1)
1373
+ if [ "$HOOKS" = "1 1 false" ] && printf '%s' "$AGAIN" | grep -q "уже стоит"; then
1374
+ ok "хук ставится один раз, переносимой командой, чужие настройки целы"
1375
+ else
1376
+ bad "установка хука испортила настройки или задвоилась" "разбор: $HOOKS"
1377
+ fi
1378
+ rm -rf "$CTXP"
1379
+
1380
+ # --- 92. полный блок: карта и свод дословно ----------------------------------
1381
+ # Решение владельца 2026-09-08, принятое ПОСЛЕ возражения про длину входа: агент читает файлы
1382
+ # плохо, и лишние токены — плата за то, чтобы он не ошибался. Раз плата внесена, товар обязан
1383
+ # быть доставлен: свод дословно, а не пересказ, и карта команд, а не половина карты.
1384
+ FULP="$(mktemp -d)"
1385
+ ( cd "$FULP" && git init -q . &&
1386
+ printf '# Свод\n\n- Правило-маячок-для-проверки. <!-- aqk: человек -->\n' > AGENTS.md &&
1387
+ printf 'aqk: 1\nentry:\n - AGENTS.md\n' > .aqk.yml ) >/dev/null 2>&1
1388
+ FUL=$( cd "$FULP" && AQK_LANG=ru node "$CLI" context --full 2>&1 )
1389
+ SHORT=$( cd "$FULP" && AQK_LANG=ru node "$CLI" context 2>&1 )
1390
+ if printf '%s' "$FUL" | grep -q "Правило-маячок-для-проверки" &&
1391
+ printf '%s' "$FUL" | grep -q "doctor --run --since main" &&
1392
+ printf '%s' "$FUL" | grep -q "ЧТО УМЕЕТ ЭТОТ ИНСТРУМЕНТ" &&
1393
+ ! printf '%s' "$SHORT" | grep -q "Правило-маячок-для-проверки"; then
1394
+ ok "context --full несёт карту и свод дословно, обычный — нет"
1395
+ else
1396
+ bad "полный блок не донёс свод или карту" "$(printf '%s' "$FUL" | head -4)"
1397
+ fi
1398
+
1399
+ # --- 93. хук с --full ставится именно с флагом -------------------------------
1400
+ ( cd "$FULP" && AQK_LANG=ru node "$CLI" context --full --install ) >/dev/null 2>&1
1401
+ FLAG=$(node_in "$FULP" -e 'const s=require("./.claude/settings.json");
1402
+ console.log(JSON.stringify(s.hooks?.SessionStart||[]).includes("--full"))' 2>&1)
1403
+ if [ "$FLAG" = "true" ]; then
1404
+ ok "хук, поставленный с --full, зовёт полный блок, а не короткий"
1405
+ else
1406
+ bad "хук потерял --full — вливался бы короткий блок" "разбор: $FLAG"
1407
+ fi
1408
+ rm -rf "$FULP"
1409
+
1410
+ # --- 94. отсев сгенерированных файлов: края, а не середина -------------------
1411
+ # `drop_generated` заменил цикл `while read; do is_generated; done` — два процесса на файл.
1412
+ # Замер на шести чужих проектах дал ноль расхождений, но отсеяно там было 5 файлов из 1242:
1413
+ # такая сверка почти ничего не доказывает. Набор ниже нарочный, и он сразу нашёл ДВА
1414
+ # расхождения, которых на живых проектах не было, — пробел и двоеточие в имени файла.
1415
+ GENP="$(mktemp -d)"
1416
+ mkg() { printf '%b' "$2" > "$GENP/$1"; }
1417
+ mkg 'l1.js' '// @generated\ncode\n'
1418
+ mkg 'l5.js' 'a\nb\nc\nd\n// @generated\n'
1419
+ mkg 'l6.js' 'a\nb\nc\nd\ne\n// @generated\n'
1420
+ mkg 'upper.js' '// @GENERATED\n'
1421
+ mkg 'donotedit.js' '// Do Not Edit\n'
1422
+ mkg 'autogen.js' '// auto-generated by tool\n'
1423
+ mkg 'genby.js' '// Generated by protoc\n'
1424
+ mkg 'cyr.js' '// сгенерирован автоматически\n'
1425
+ mkg 'cyrup.js' '// СГЕНЕРИРОВАН\n'
1426
+ mkg 'with space.js' '// @generated\n'
1427
+ mkg 'with:colon.js' '// @generated\n'
1428
+ mkg 'clean.js' 'const x = 1;\n'
1429
+ : > "$GENP/empty.js"
1430
+ KEPT=$( . "$ROOT/kit/gates/_skip.sh"; find "$GENP" -type f | sort | drop_generated | sed "s#$GENP/##" | sort | tr '\n' ' ' )
1431
+ # Ожидания абсолютные, а не «как было раньше»: старая функция однажды уйдёт, а края останутся.
1432
+ # l6 остаётся намеренно: маркер на шестой строке — это уже не шапка файла.
1433
+ if [ "$KEPT" = "clean.js empty.js l6.js " ]; then
1434
+ ok "сгенерированные отсеяны по всем маркерам; пробел, двоеточие и граница 5-й строки учтены"
1435
+ else
1436
+ bad "отсев сгенерированных изменился" "осталось: $KEPT"
1437
+ fi
1438
+ rm -rf "$GENP"
1439
+
1200
1440
  # --- итог -------------------------------------------------------------------
1201
1441
  printf '\n'
1202
1442
  if [ "$FAIL" -eq 0 ]; then
@@ -0,0 +1,186 @@
1
+ // tool/selfcheck/units-context.mjs — блок состояния, который уходит В КОНТЕКСТ агента.
2
+ //
3
+ // ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ. У этого текста единственный читатель — машина, и цена ошибки другая,
4
+ // чем у терминального вывода: человек, увидев пустую строку, переспросит, а агент примет её за
5
+ // утверждение. Поэтому главная проверка здесь одна и та же во всех видах: **тишина не означает
6
+ // «чисто»**. Блок обязан говорить «неизвестно» там, где не знает, — иначе он врёт ровно тем
7
+ // способом, против которого написан весь комплект.
8
+ //
9
+ // node --test tool/selfcheck/units-context.mjs
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf } from "../commands/context.mjs";
13
+ import { CATALOGS } from "../i18n/index.mjs";
14
+ import { commandRows } from "../lib/core.mjs";
15
+ import { readFile } from "node:fs/promises";
16
+
17
+ const T = CATALOGS.ru.context;
18
+ const base = {
19
+ entry: "AGENTS.md",
20
+ level: { reached: 1, top: 3, missing: "гейты не доказаны" },
21
+ rules: { total: 14, machine: 2, human: 12 },
22
+ run: { when: "2026-09-08 11:00", red: [], skipped: 0, stale: false },
23
+ ratchets: [],
24
+ };
25
+ const text = (over = {}) => contextBlock({ ...base, ...over }, T).join("\n");
26
+
27
+ // ГЛАВНАЯ. Прогона не было — сказать «неизвестно» словом. Пустая строка на этом месте
28
+ // прочитается агентом как «красных нет», и он пойдёт писать код по несуществующему разрешению.
29
+ test("без прогона блок говорит «неизвестно», а не молчит", () => {
30
+ const t = text({ run: null });
31
+ assert.match(t, /НЕИЗВЕСТНО/);
32
+ assert.doesNotMatch(t, /красных нет/);
33
+ });
34
+
35
+ test("красные гейты названы поимённо", () => {
36
+ const t = text({ run: { ...base.run, red: ["file-size-limit", "duplicate-code"] } });
37
+ assert.match(t, /file-size-limit/);
38
+ assert.match(t, /duplicate-code/);
39
+ });
40
+
41
+ // Проект с двадцатью красными не должен вытеснять собой весь контекст: ровно та деградация,
42
+ // ради избежания которой блок и делается коротким.
43
+ test("длинный список красных обрезается и называет остаток числом", () => {
44
+ const red = Array.from({ length: 20 }, (_, i) => `гейт-${i}`);
45
+ const t = text({ run: { ...base.run, red } });
46
+ assert.match(t, /ещё 15/);
47
+ assert.ok(!t.includes("гейт-9"), "шестой и дальше в список не попадают");
48
+ });
49
+
50
+ // Прогон, сделанный до последнего коммита, описывает не тот код, что лежит перед агентом.
51
+ test("устаревший прогон помечен, а не выдан за свежий", () => {
52
+ const t = text({ run: { ...base.run, stale: true } });
53
+ assert.match(t, /СТАРЕЕ/);
54
+ });
55
+
56
+ // То, ради чего второй пользователь и оценил promise-has-gate: «держит человек» значит
57
+ // «не держит никто», и это обязано быть сказано словами, а не выведено читателем из цифр.
58
+ test("правила без машинного арбитра названы прямо", () => {
59
+ const t = text({ rules: { total: 12, machine: 0, human: 12 } });
60
+ assert.match(t, /не держит никто/);
61
+ });
62
+
63
+ test("нет манифеста — уровень не выдумывается", () => {
64
+ const t = text({ level: null });
65
+ assert.match(t, /не вычислен/);
66
+ assert.doesNotMatch(t, /AQK-0 из/);
67
+ });
68
+
69
+ // Потолок: блок влезает в глаза целиком. Замер 2026-09-08 — вход целиком в контекст роняет
70
+ // точность у всех проверенных моделей по мере роста, поэтому предел здесь предмет проверки,
71
+ // а не пожелание.
72
+ test("блок не разрастается даже на худшем входе", () => {
73
+ const lines = contextBlock({
74
+ ...base,
75
+ run: { when: "…", red: Array.from({ length: 40 }, (_, i) => `г-${i}`), skipped: 9, stale: true },
76
+ ratchets: Array.from({ length: 12 }, (_, i) => ({ name: `р-${i}`, count: i })),
77
+ }, T);
78
+ assert.ok(lines.length <= 16, `строк ${lines.length}, предел 16`);
79
+ });
80
+
81
+ // --- разбор источников -------------------------------------------------------
82
+ // Имя арбитра — это имя гейта, а в именах гейтов есть дефисы. Класс `[^\s>-]` обрывал их
83
+ // молча: первый же живой запуск показал 13 правил вместо 14 и одного машинного вместо двух.
84
+ // Молча — потому что число выглядит правдоподобным, пока его не с чем сверить.
85
+ test("имя арбитра с дефисом считается машинным, а не теряется", () => {
86
+ const md = [
87
+ "- Правило один. <!-- aqk: deps-are-pinned -->",
88
+ "- Правило два. <!-- aqk: человек -->",
89
+ "- Правило три. <!-- aqk: gates -->",
90
+ ].join("\n");
91
+ assert.deepEqual(countArbiters(md, ["человек"]), { total: 3, machine: 2, human: 1 });
92
+ });
93
+
94
+ test("отчёт прошлого прогона отдаёт красные и число непроверенных", () => {
95
+ const r = parseLastRun([
96
+ "# aqk doctor --run — 2026-09-08 11:00",
97
+ "level: AQK-1",
98
+ "✔ smoke — 1.0s",
99
+ "✘ file-size-limit — 0.1s",
100
+ "~ dead-code — нет инструмента",
101
+ ].join("\n"));
102
+ assert.equal(r.when, "2026-09-08 11:00");
103
+ assert.deepEqual(r.red, ["file-size-limit"]);
104
+ assert.equal(r.skipped, 1);
105
+ });
106
+
107
+ test("пустой отчёт — это не «чисто», а отсутствие данных", () => {
108
+ assert.equal(parseLastRun(""), null);
109
+ });
110
+
111
+ // Найдено ЗАМЕРОМ на шести чужих проектах, до того как хук попал в init: на `flask`, где нет
112
+ // ни .aqk.yml, ни AGENTS.md, блок всё равно писал «Свод правил: AGENTS.md». Тот же класс, что
113
+ // у doctor неделей раньше: умолчание выдаётся за факт, потому что на нашем репозитории
114
+ // умолчание и факт совпадают. Назвать агенту несуществующий файл хуже, чем промолчать: он
115
+ // пойдёт его читать и получит пустоту вместо правил.
116
+ test("несуществующая точка входа не называется как свод правил", () => {
117
+ const t = text({ level: null, run: null, rules: null, entryExists: false });
118
+ assert.doesNotMatch(t, /AGENTS\.md/);
119
+ });
120
+
121
+ test("существующая точка входа называется", () => {
122
+ const t = text({ entryExists: true });
123
+ assert.match(t, /AGENTS\.md/);
124
+ });
125
+
126
+ // --- хук уходит в ОБЩИЙ файл настроек ----------------------------------------
127
+ // `.claude/settings.json` кладут в git: он общий на команду, в отличие от settings.local.json.
128
+ // Значит команда внутри него обязана работать не только на той машине, где её записали.
129
+ // На машине разработчика SELF — абсолютный путь; у соседа такого пути нет, и хук молча
130
+ // не сработает. Молча — то есть блок состояния просто не появится, и никто не узнает.
131
+ test("абсолютный путь заменяется переносимым вызовом", () => {
132
+ assert.equal(portableSelf("node /home/x/aqk/tool/program.mjs"), "npx agent-quality-kit");
133
+ assert.equal(portableSelf("node C:\\x\\aqk\\tool\\program.mjs"), "npx agent-quality-kit");
134
+ });
135
+
136
+ test("переносимые вызовы остаются как есть", () => {
137
+ assert.equal(portableSelf("aqk"), "aqk");
138
+ assert.equal(portableSelf("npx agent-quality-kit"), "npx agent-quality-kit");
139
+ assert.equal(portableSelf("node tool/program.mjs"), "node tool/program.mjs");
140
+ });
141
+
142
+ // Второй такой же хук — блок в контексте дважды: вдвое больше токенов и ровно ноль пользы.
143
+ test("хук не задваивается", () => {
144
+ const once = withHook({}, "aqk context");
145
+ assert.ok(hasOurHook(once, "aqk context"));
146
+ assert.equal(once.hooks.SessionStart.length, 1);
147
+ });
148
+
149
+ // Чужие настройки в том же файле — права доступа, другие хуки — обязаны пережить установку.
150
+ test("чужие настройки переживают установку хука", () => {
151
+ const before = { permissions: { deny: ["Read(./.env)"] }, hooks: { Stop: [{ hooks: [] }] } };
152
+ const after = withHook(before, "aqk context");
153
+ assert.deepEqual(after.permissions, before.permissions);
154
+ assert.equal(after.hooks.Stop.length, 1);
155
+ assert.equal(after.hooks.SessionStart.length, 1);
156
+ });
157
+
158
+ // --- карта команд ------------------------------------------------------------
159
+ // Владелец: «агент плохо читает инструкцию — нужно влить карту и сам свод». Карта имеет смысл
160
+ // ровно до тех пор, пока она не отстала от программы. Второй список, живущий рядом с первым,
161
+ // через месяц врёт — это записано у нас в README про храповики и верно здесь буквально так же.
162
+ // Поэтому карта и справка собираются ИЗ ОДНОГО списка, а эта проверка сторожит, что список
163
+ // не отстал от диспетчера: команда, добавленная в switch и забытая в списке, роняет её.
164
+ test("карта команд не отстаёт от диспетчера", async () => {
165
+ const src = await readFile(new URL("../program.mjs", import.meta.url), "utf8");
166
+ const dispatched = [...src.matchAll(/^\s{4}case "([a-z-]+)":/gm)].map((m) => m[1]);
167
+ assert.ok(dispatched.length >= 10, `в диспетчере найдено ${dispatched.length} команд — разбор сломался`);
168
+ const listed = new Set(commandRows(CATALOGS.ru).map((r) => r.name));
169
+ const missing = dispatched.filter((n) => !listed.has(n));
170
+ assert.deepEqual(missing, [], `в карте нет: ${missing.join(", ")}`);
171
+ });
172
+
173
+ // Полный блок — то, за что владелец согласился платить токенами: свод правил дословно, а не
174
+ // ссылка на него. Если он не дословный, плата внесена, а товар не получен.
175
+ test("полный блок несёт свод правил дословно", () => {
176
+ const rules = "- Правило одно. <!-- aqk: человек -->\n- Правило два.";
177
+ const t = contextBlock({ ...base, entryExists: true, full: { entry: "AGENTS.md", rows: [{ cmd: "aqk context", text: "состояние" }], text: rules } }, T).join("\n");
178
+ assert.ok(t.includes(rules), "текст свода обязан войти целиком");
179
+ assert.match(t, /aqk context/);
180
+ });
181
+
182
+ test("без --full свод не вливается — только ссылка на него", () => {
183
+ const t = text({ entryExists: true });
184
+ assert.doesNotMatch(t, /Правило одно/);
185
+ assert.match(t, /AGENTS\.md/);
186
+ });