agent-quality-kit 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.
Files changed (61) hide show
  1. package/README.md +135 -9
  2. package/README.ru.md +167 -23
  3. package/kit/docs/ai/project-baseline.md +14 -0
  4. package/kit/docs/ready-made-rules.md +103 -0
  5. package/kit/gates/_skip.sh +61 -1
  6. package/kit/gates/ci-not-hijackable/README.md +56 -0
  7. package/kit/gates/ci-not-hijackable/check.sh +73 -0
  8. package/kit/gates/ci-not-hijackable/gate.yml +19 -0
  9. package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
  10. package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
  11. package/kit/gates/color-from-token/check.sh +10 -2
  12. package/kit/gates/color-from-token/green/Button.tsx +2 -0
  13. package/kit/gates/complexity-limit/check.sh +6 -7
  14. package/kit/gates/duplicate-code/check.sh +5 -1
  15. package/kit/gates/entry-links-exist/check.sh +4 -1
  16. package/kit/gates/entry-links-exist/green/AGENTS.md +2 -0
  17. package/kit/gates/file-size-limit/check.sh +1 -1
  18. package/kit/gates/lesson-has-outcome/check.sh +5 -1
  19. package/kit/gates/mcp-server-resolves/README.md +62 -0
  20. package/kit/gates/mcp-server-resolves/check.sh +110 -0
  21. package/kit/gates/mcp-server-resolves/gate.yml +18 -0
  22. package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
  23. package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
  24. package/kit/gates/secrets-not-in-code/check.sh +16 -3
  25. package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
  26. package/kit/gates/todo-without-task/check.sh +1 -1
  27. package/kit/gates/todo-without-task/green/app.py +1 -0
  28. package/llms.txt +38 -2
  29. package/package.json +2 -3
  30. package/tool/commands/context.mjs +264 -0
  31. package/tool/commands/doctor.mjs +104 -28
  32. package/tool/commands/learn.mjs +159 -0
  33. package/tool/commands/project.mjs +19 -2
  34. package/tool/commands/prove.mjs +1 -0
  35. package/tool/commands/report.mjs +33 -1
  36. package/tool/commands/vitals.mjs +159 -0
  37. package/tool/i18n/en-docs.mjs +125 -1
  38. package/tool/i18n/en.mjs +39 -36
  39. package/tool/i18n/index.mjs +36 -3
  40. package/tool/i18n/ru-docs.mjs +127 -1
  41. package/tool/i18n/ru.mjs +39 -36
  42. package/tool/lib/banner.mjs +59 -0
  43. package/tool/lib/brief.mjs +192 -0
  44. package/tool/lib/core.mjs +32 -1
  45. package/tool/lib/evidence.mjs +124 -0
  46. package/tool/lib/manifest.mjs +173 -15
  47. package/tool/lib/prove.mjs +24 -2
  48. package/tool/lib/repo.mjs +31 -1
  49. package/tool/lib/scope.mjs +10 -1
  50. package/tool/lib/templates.mjs +1 -0
  51. package/tool/program.mjs +45 -23
  52. package/tool/selfcheck/smoke.sh +592 -3
  53. package/tool/selfcheck/units-banner.mjs +65 -0
  54. package/tool/selfcheck/units-brief.mjs +97 -0
  55. package/tool/selfcheck/units-context.mjs +188 -0
  56. package/tool/selfcheck/units-evidence.mjs +83 -0
  57. package/tool/selfcheck/units-learn.mjs +88 -0
  58. package/tool/selfcheck/units-level.mjs +211 -3
  59. package/tool/selfcheck/units-repo.mjs +134 -0
  60. package/tool/selfcheck/units-vitals.mjs +62 -0
  61. package/tool/selfcheck/units.mjs +4 -75
@@ -0,0 +1,16 @@
1
+ {
2
+ "mcpServers": {
3
+ "browser": {
4
+ "command": "npx",
5
+ "args": ["-y", "chrome-devtools-mcp@latest"]
6
+ },
7
+ "memory": {
8
+ "command": "/opt/tools/memory-mcp-v0.10.8/bin/memory",
9
+ "args": []
10
+ },
11
+ "search": {
12
+ "command": "npx",
13
+ "args": ["-y", "some-search-mcp"]
14
+ }
15
+ }
16
+ }
@@ -18,9 +18,22 @@ fi
18
18
  # Красный образец — намеренно сломанный код в репозитории. Сканирующий гейт обязан его
19
19
  # пропускать, иначе будет вечно краснеть на том, что сам же и положил. Исключение снимается,
20
20
  # когда проверяют сам образец: тогда каталог red и есть цель проверки.
21
- HITS=$(grep -rInE $(skip_grep "$DIR") -e \
22
- '-----BEGIN [A-Z ]*PRIVATE KEY-----|(sk|pk)_(live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]{10,}' \
23
- "$DIR" 2>/dev/null | grep -v '/\.git/' | own_samples_filter "$DIR")
21
+ # Два прохода, а не один, и разница между ними измерена. Живой облачный ключ — находка везде,
22
+ # включая тестовые каталоги: он открывает настоящий счёт. Приватный КЛЮЧ в тестовых данных —
23
+ # норма: любой проект с проверками TLS кладёт туда сертификат, и Go так делает по соглашению.
24
+ # Замер 2026-09-08 по шести чужим репозиториям: gin краснел на testdata/certificate/key.pem,
25
+ # положенном туда намеренно. Гейт, краснеющий на нормальном укладе, выключают в первый день —
26
+ # и тогда он не ловит уже НИЧЕГО, включая настоящий ключ.
27
+ TOKENS='(sk|pk)_(live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]{10,}'
28
+ PRIVKEY='-----BEGIN [A-Z ]*PRIVATE KEY-----'
29
+ # Каталоги образцов: только те, чьё имя не оставляет сомнений. `data` или `assets` сюда не
30
+ # входят — там ключ вполне может оказаться настоящим.
31
+ FIXTURES='(^|/)(testdata|fixtures|__fixtures__|test|tests|spec|__tests__)/'
32
+
33
+ HITS=$(
34
+ { grep -rInE $(skip_grep "$DIR") -e "$TOKENS" "$DIR" 2>/dev/null
35
+ grep -rInE $(skip_grep "$DIR") -e "$PRIVKEY" "$DIR" 2>/dev/null | grep -vE "$FIXTURES"
36
+ } | grep -v '/\.git/' | own_samples_filter "$DIR" | sort -u)
24
37
  if [ -n "$HITS" ]; then
25
38
  echo "$HITS" | cut -c1-160
26
39
  echo " почини: убери значение из файла, положи его в переменную окружения и отзови старый ключ."
@@ -0,0 +1,3 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF32rHzS0AAAAAAAAAAAAAAAAAAAA
3
+ -----END RSA PRIVATE KEY-----
@@ -21,7 +21,7 @@ fi
21
21
  # названием и на собственном шаблоне поиска — на том, что дефектом не является.
22
22
  # Сами эти слова здесь не пишем: гейт нашёл бы себя. Проверено — находил.
23
23
  HITS=$(grep -rnE $(skip_grep "$DIR") $(include_code) \
24
- '(#|//|/\*|--|<!--)[^"'"'"']*(^|[^A-Za-z])(TODO|FIXME|HACK|XXX)([^A-Za-z]|$)' "$DIR" 2>/dev/null | own_samples_filter "$DIR")
24
+ '(#|//|/\*|--|<!--)[^"'"'"']*(^|[^A-Za-z0-9_])(TODO|FIXME|HACK|XXX)([^A-Za-z0-9_]|$)' "$DIR" 2>/dev/null | own_samples_filter "$DIR")
25
25
  if [ -n "$HITS" ]; then
26
26
  echo "$HITS"
27
27
  echo " почини: заведи задачу в очереди работ, маркер убери."
@@ -0,0 +1 @@
1
+ UV_TEST_XXX = "имя переменной, а не маркер долга"
package/llms.txt CHANGED
@@ -23,11 +23,46 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
23
23
  the other 36 are named as a number, not hidden)
24
24
  - Fail a pipeline below a level or on a failed gate: `npx agent-quality-kit doctor --run --min 1`
25
25
  - Show only what a diff introduced, so a legacy repo is usable from day one: `doctor --run --since main`
26
+ - Prove the gates actually catch defects: `npx agent-quality-kit prove` — every *provable* gate is
27
+ run against its own red and green sample and must go red on the first and stay quiet on the
28
+ second. A gate with no samples, with samples written for another recipe, whose command takes
29
+ no directory, or whose `requires:` program is not installed is reported as unprovable and
30
+ named — never as broken; the verdict is "nothing proven is broken, and
31
+ at least one gate is proven". Level AQK-2 and the badge depend on this, not on the presence of
32
+ files
33
+ - See what proves a diff, file by file: `npx agent-quality-kit report --since main` — each changed
34
+ code file is named by a check, walked past in silence, or touched by nothing at all, plus a
35
+ fingerprint over the base, the commands and the file contents
36
+ - Put the repository state into the agent's context instead of hoping it reads the files:
37
+ `npx agent-quality-kit context` — level, what is red right now, how many rules no machine
38
+ enforces, what the ratchets hold. Where it does not know, it says so: a run that never happened
39
+ is reported as unknown, never as clean. `context --install` writes a `SessionStart` hook into
40
+ `.claude/settings.json` (Claude Code only; the rest of the kit stays vendor-neutral). Measured:
41
+ the block is ~375 tokens, and carries what a file cannot — what changed today. `context --full`
42
+ adds the command map and the rulebook verbatim (~7000 tokens): a deliberate trade, chosen by
43
+ the owner after the objection about long inputs, on the grounds that an agent reads files
44
+ poorly and the tokens are the price of it not guessing
45
+ - Check that the kit's own wiring is actually connected: `npx agent-quality-kit vitals` — are the
46
+ tools the declared gates need installed, is the hook present in `.git/hooks` (a line in the
47
+ config is an intention, not a guard), does the agent receive the state, is the version current.
48
+ Four states, not two: connected · BROKEN · not connected and that is a choice · could not look.
49
+ Only BROKEN affects the exit code
50
+ - See what you told the agent and never wrote down: `npx agent-quality-kit learn` — reads Claude Code
51
+ transcripts for this project on this machine and prints rule candidates missing from the entry
52
+ point. Current project only, terminal only, writes nothing, always exits 0
26
53
  - Exit codes: 0 pass, 1 below the level or a gate failed
27
54
  - As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
28
55
  `id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
29
56
  package itself; there are no dependencies to pull in.
30
- - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.7.0` with `min: 1`
57
+ - Without Node at all (a Python, Go or Rust project where nobody installed it):
58
+ `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/work" ghcr.io/arsen-ask-lx/aqk doctor`.
59
+ Published from v0.9.0 onward, by the same run that publishes the package; before that tag,
60
+ build it from the repository: `docker build -t aqk . && docker run --rm -u "$(id -u):$(id -g)"
61
+ -v "$PWD:/work" aqk doctor`. Keep the `--user` flag: without it the container runs as root and
62
+ the files `init` writes are owned by root, so you cannot edit your own manifest. Debian-based
63
+ on purpose: the gates are `sh`, `grep`, `awk`, `find` — under alpine's busybox they behave
64
+ differently, and an image where the gates behave differently is worse than no image
65
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.9.0` with `min: 1`
31
66
  (https://github.com/marketplace/actions/agent-quality-kit-aqk)
32
67
 
33
68
  ## What makes it different
@@ -43,7 +78,8 @@ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
43
78
  ## Files it reads and writes
44
79
 
45
80
  - `AGENTS.md` — what the agent reads first (the entry point; `CLAUDE.md` and others work too)
46
- - `.aqk.yml` — the manifest: entry, rules, gates as commands, samples, ratchets, lessons
81
+ - `.aqk.yml` — the manifest: entry, rules, docs, lang, gates as commands, covers (what a
82
+ declared gate already holds, so it is not reported as debt), samples, ratchets, lessons
47
83
  - `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
48
84
 
49
85
  ## Documentation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Turns the rules an agent is supposed to follow into commands with exit codes, and reports which of them actually run. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,8 +43,7 @@
43
43
  ],
44
44
  "knip": {
45
45
  "entry": [
46
- "tool/selfcheck/units.mjs",
47
- "tool/selfcheck/units-level.mjs",
46
+ "tool/selfcheck/units*.mjs",
48
47
  "tool/selfcheck/lifecycle.mjs"
49
48
  ],
50
49
  "project": [
@@ -0,0 +1,264 @@
1
+ // tool/commands/context.mjs — состояние репозитория одним коротким блоком, для КОНТЕКСТА агента.
2
+ //
3
+ // ЗАЧЕМ ЭТА КОМАНДА ВООБЩЕ. Первый чужой отзыв, 2026-09-08, раздел «где я сам применил неверно»:
4
+ // «ставил записи, не читая их gate.yml», «не знал, как устроен prove», «не пользовался половиной
5
+ // команд». Файлы лежали. Агент до них не дошёл. Файл — приглашение прочитать, и агент вправе им
6
+ // не воспользоваться; хук `SessionStart` кладёт текст в контекст ДО первого действия, и отказаться
7
+ // от него нельзя. Это и есть вся разница.
8
+ //
9
+ // ПОЧЕМУ НЕ ВЕСЬ СВОД. Соблазн влить в контекст всё правила целиком. Замерено чужими руками и
10
+ // не нами: вход, растущий в длину, роняет качество у ВСЕХ проверенных передовых моделей — модель
11
+ // с окном 200K заметно деградирует уже на 50K, а ближние токены выигрывают у дальних. То есть
12
+ // «влить всё вперёд» даёт обратный результат: правило в контексте есть и не выполняется — ровно
13
+ // тот отказ, против которого весь комплект. Наш замер: этот блок ≈147 токенов, AGENTS.md ≈3348.
14
+ //
15
+ // ПОЭТОМУ ЗДЕСЬ СОСТОЯНИЕ, А НЕ ПРАВИЛА. Свод статичен и лежит в файле — агент его прочитает по
16
+ // ссылке. А вот чего из файла не узнать никогда: какой сейчас уровень, что красное ПРЯМО СЕЙЧАС,
17
+ // сколько правил не держит никто, что лежит в храповике. Это меняется каждый день, и записать
18
+ // это в AGENTS.md значит завести второй список, который через месяц врёт.
19
+ //
20
+ // ТИШИНА НЕ ОЗНАЧАЕТ «ЧИСТО». Читатель здесь машина: человек, увидев пустое место, переспросит,
21
+ // а агент примет его за утверждение. Поэтому каждое незнание называется словом: прогона не было —
22
+ // так и написано, прогон устарел — тоже, инструмента нет — тоже.
23
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
24
+ import { spawnSync } from "node:child_process";
25
+ import { join } from "node:path";
26
+ import { CWD, TARGET_DIR, SELF, c, exists, commandRows } from "../lib/core.mjs";
27
+ import { readManifest, assessLevel } from "../lib/manifest.mjs";
28
+ import { L } from "../i18n/index.mjs";
29
+
30
+ // Больше пяти имён подряд агент всё равно не удержит, а блок ради них раздувается. Остаток
31
+ // называется числом: «и ещё 15» — это факт, а молчание про них было бы враньём.
32
+ const MAX_RED = 5;
33
+ const MAX_RATCHETS = 3;
34
+
35
+ // Чистая функция: на входе состояние, на выходе строки. Отделена от чтения диска намеренно —
36
+ // это единственное место комплекта, чей текст читает машина, и проверять его надо не прогоном,
37
+ // а перебором случаев, включая те, которых на нашем репозитории не бывает.
38
+ function contextBlock(state, T = L.context) {
39
+ const out = [T.title, ""];
40
+
41
+ out.push(state.level
42
+ ? T.level(state.level.reached, state.level.top, state.level.missing)
43
+ : T.levelUnknown);
44
+
45
+ if (state.rules && state.rules.total) {
46
+ const { total, machine, human } = state.rules;
47
+ out.push(T.rules(total, machine, human) + (human > 0 ? ` ${T.rulesNobody}` : ""));
48
+ }
49
+
50
+ if (!state.run) {
51
+ out.push(T.runNone);
52
+ } else {
53
+ const red = state.run.red || [];
54
+ const shown = red.slice(0, MAX_RED);
55
+ const names = red.length > MAX_RED
56
+ ? `${shown.join(", ")} — ${T.andMore(red.length - MAX_RED)}`
57
+ : shown.join(", ");
58
+ out.push(red.length ? T.runRed(state.run.when, names) : T.runClean(state.run.when));
59
+ if (state.run.stale) out.push(T.runStale(state.run.when));
60
+ if (state.run.skipped) out.push(T.skipped(state.run.skipped));
61
+ }
62
+
63
+ const rat = (state.ratchets || []).slice(0, MAX_RATCHETS);
64
+ if (rat.length) out.push(T.ratchets(rat.map((r) => `${r.name} (${r.count})`).join(", ")));
65
+
66
+ // ПОЛНЫЙ БЛОК — решение владельца от 2026-09-08, принятое ПОСЛЕ возражения и вопреки ему.
67
+ // Возражение было такое: вход, растущий в длину, роняет качество у всех проверенных моделей,
68
+ // и свод, влитый целиком, даёт правило, которое в контексте есть и не выполняется. Ответ
69
+ // владельца: агент читает файлы плохо, это видно на живых примерах, и лишние токены — плата
70
+ // за то, чтобы он не ошибался. Решение записано здесь, а не спрятано в истории команд,
71
+ // потому что через месяц «почему тут вливается всё» будет непонятно никому.
72
+ //
73
+ // Умолчание осталось коротким: платит тот, кто выбрал платить.
74
+ if (state.full) {
75
+ out.push("", T.mapTitle);
76
+ // Ширина колонки считается, а не подбирается: имена команд разной длины в двух языках,
77
+ // и вручную выставленный отступ разъезжается на первом же переводе. Та же причина, что
78
+ // в справке program.mjs, — и это ещё один довод держать список общим.
79
+ const w = Math.max(...state.full.rows.map((r) => r.cmd.length));
80
+ for (const r of state.full.rows) out.push(` ${r.cmd.padEnd(w)} ${r.text}`);
81
+ if (state.full.text) {
82
+ out.push("", T.rulesTitle(state.full.entry), "");
83
+ out.push(state.full.text.trimEnd());
84
+ }
85
+ }
86
+
87
+ // Ссылка на свод даётся, только если файл ЕСТЬ. Назвать агенту несуществующий файл хуже,
88
+ // чем промолчать: он пойдёт его читать и получит пустоту вместо правил. Замерено на шести
89
+ // чужих проектах: на flask блок писал «Свод правил: AGENTS.md», которого там нет.
90
+ if (state.entryExists !== false) out.push("", T.where(state.entry || "AGENTS.md"));
91
+ return out;
92
+ }
93
+
94
+ // Разбор отчёта прошлого прогона. Формат кладёт сам `doctor` в .aqk/last-run.md; читаем его,
95
+ // а не запускаем гейты заново: хук обязан укладываться в секунду-две, а прогон у нас идёт минуту.
96
+ function parseLastRun(text) {
97
+ if (!text) return null;
98
+ const when = (text.match(/^# aqk doctor --run — (.+)$/m) || [])[1] || "";
99
+ const red = [];
100
+ for (const m of text.matchAll(/^✘ ([^\s—]+)/gm)) red.push(m[1]);
101
+ const skipped = (text.match(/^~ /gm) || []).length;
102
+ return { when: when.trim(), red, skipped, stale: false };
103
+ }
104
+
105
+ // Правила и их арбитры: отметка `<!-- aqk: имя -->` рядом с правилом. `человек` — честное
106
+ // признание, что машина этого не держит; так его и считаем, отдельно от машинных.
107
+ function countArbiters(text, humanWords) {
108
+ // Имя арбитра — это имя гейта, а в нём дефисы: `deps-are-pinned`. Класс исключения `[^\s>-]`
109
+ // обрывал такое имя и не считал его вовсе. Найдено первым же живым запуском: на нашем своде
110
+ // блок показал 13 правил вместо 14 и одного машинного арбитра вместо двух.
111
+ const marks = [...String(text).matchAll(/<!--\s*aqk:\s*(\S+?)\s*-->/g)].map((m) => m[1]);
112
+ const human = marks.filter((w) => humanWords.includes(w.toLowerCase())).length;
113
+ return { total: marks.length, machine: marks.length - human, human };
114
+ }
115
+
116
+ // Прогон старше последнего коммита описывает не тот код, что лежит перед агентом. Молча выдать
117
+ // его за свежий — соврать: именно так «зелёный месяц назад» превращается в «зелёный сейчас».
118
+ function runIsStale(when) {
119
+ if (!when) return false;
120
+ const r = spawnSync("git", ["log", "-1", "--format=%cI"], { cwd: CWD, encoding: "utf8" });
121
+ if (r.status !== 0 || !r.stdout) return false;
122
+ const commit = Date.parse(r.stdout.trim());
123
+ const run = Date.parse(when.replace(" ", "T"));
124
+ return Number.isFinite(commit) && Number.isFinite(run) && run < commit;
125
+ }
126
+
127
+
128
+ // УСТАНОВКА ХУКА — отдельной командой, а не частью `init`, и это решение, а не лень. Комплект
129
+ // нейтрален к вендору: правила и гейты не зависят от того, какой нейросетью пишут код. Хук
130
+ // `SessionStart` — принадлежность одного Claude Code, и класть его всем подряд значило бы
131
+ // объявить нейтральность и нарушить её в первой же команде.
132
+ //
133
+ // БЕЗ MATCHER НАМЕРЕННО, и теперь по установленной причине, а не из осторожности.
134
+ // 2026-09-08 расхождение разрешено: в бинаре установленной версии 2.1.263 лежит буквальное
135
+ // перечисление источников — "startup","resume","clear","compact","fork" — и вызов
136
+ // {kind:"session-start", source:"startup"}. То есть у SessionStart источники ЕСТЬ, и `compact`
137
+ // среди них: хук срабатывает и после сжатия контекста. Это важнее, чем кажется: сжатие —
138
+ // ровно тот момент, когда состояние вылетает из окна, и без повторного срабатывания вся
139
+ // затея работала бы до первого /compact.
140
+ // Matcher не ставим потому, что нужны ВСЕ источники: и старт, и продолжение, и очистка, и
141
+ // сжатие. Отсутствие matcher означает «на любой источник» — это и требуется.
142
+ const HOOK_FILE = [".claude", "settings.json"];
143
+
144
+ // Команда, которая пойдёт В ОБЩИЙ файл настроек, а значит и в чужие руки через git. `SELF`
145
+ // печатается для человека здесь и сейчас и на машине разработчика равен АБСОЛЮТНОМУ пути —
146
+ // у соседа по команде такого пути нет, и хук у него молча не сработает. Абсолютный путь
147
+ // заменяется на переносимый вызов из реестра; `aqk` и `npx …` переносимы сами и остаются.
148
+ function portableSelf(self = SELF) {
149
+ return /^node\s+[/\\]|^node\s+[A-Za-z]:/.test(self) ? "npx agent-quality-kit" : self;
150
+ }
151
+
152
+ function hookEntry(cmd) {
153
+ return { hooks: [{ type: "command", command: cmd }] };
154
+ }
155
+
156
+ // Уже стоит? Тогда ничего не трогаем. Второй такой же хук значит блок в контексте дважды —
157
+ // вдвое больше токенов и ровно ноль пользы.
158
+ function hasOurHook(settings, cmd) {
159
+ const list = settings?.hooks?.SessionStart;
160
+ if (!Array.isArray(list)) return false;
161
+ return list.some((g) => (g?.hooks || []).some((h) => String(h?.command || "").includes(cmd)));
162
+ }
163
+
164
+ function withHook(settings, cmd) {
165
+ const next = { ...(settings || {}) };
166
+ const hooks = { ...(next.hooks || {}) };
167
+ hooks.SessionStart = [...(Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []), hookEntry(cmd)];
168
+ next.hooks = hooks;
169
+ return next;
170
+ }
171
+
172
+ async function installHook(full = false) {
173
+ const T = L.context;
174
+ const path = join(CWD, ...HOOK_FILE);
175
+ const cmd = `${portableSelf()} context${full ? " --full" : ""}`;
176
+
177
+ let settings = {};
178
+ let existed = false;
179
+ if (await exists(path)) {
180
+ existed = true;
181
+ try {
182
+ settings = JSON.parse(await readFile(path, "utf8"));
183
+ } catch {
184
+ // Чужой файл с испорченным JSON перезаписывать нельзя: там могут быть чьи-то права
185
+ // доступа, и молча стереть их дороже, чем не поставить хук.
186
+ console.log(c.red(` ${T.hookBadJson(path)}`));
187
+ return;
188
+ }
189
+ }
190
+
191
+ if (hasOurHook(settings, cmd)) {
192
+ console.log(c.dim(` ${T.hookAlready(path)}`));
193
+ return;
194
+ }
195
+
196
+ await mkdir(join(CWD, HOOK_FILE[0]), { recursive: true });
197
+ await writeFile(path, JSON.stringify(withHook(settings, cmd), null, 2) + "\n", "utf8");
198
+ console.log(c.green(` ${existed ? T.hookAdded(path) : T.hookCreated(path)}`));
199
+ console.log(c.dim(` ${JSON.stringify({ SessionStart: [hookEntry(cmd)] })}`));
200
+ console.log(c.dim(` ${T.hookWhat}`));
201
+ }
202
+
203
+ async function cmdContext(args = []) {
204
+ const full = args.includes("--full");
205
+ if (args.includes("--install")) return installHook(full);
206
+
207
+ const man = await readManifest();
208
+ const entry = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim()
209
+ || "AGENTS.md";
210
+
211
+ let level = null;
212
+ if (man?.aqk) {
213
+ const { reached, steps } = await assessLevel(man, null);
214
+ const next = steps.find((s) => !s.ok);
215
+ // `assessLevel` без прогона помечает вторую ступень `needsProof`: файлы на месте, а гейты
216
+ // не доказаны. Сказать здесь «заведи samples и ratchets» значит послать чинить сделанное —
217
+ // ровно та жалоба, с которой пришёл первый чужой отзыв, только в другом месте программы.
218
+ const missing = !next ? ""
219
+ : next.needsProof ? L.doctor.levelUnproven(`${SELF} prove`)
220
+ : next.need || next.title || "";
221
+ level = { reached, top: steps.length - 1, missing };
222
+ }
223
+
224
+ let rules = null;
225
+ if (await exists(join(CWD, entry))) {
226
+ rules = countArbiters(await readFile(join(CWD, entry), "utf8"), ["человек", "human", "nobody"]);
227
+ }
228
+
229
+ let run = null;
230
+ const lastRun = join(CWD, TARGET_DIR, "last-run.md");
231
+ if (await exists(lastRun)) {
232
+ run = parseLastRun(await readFile(lastRun, "utf8"));
233
+ if (run) run.stale = runIsStale(run.when);
234
+ }
235
+
236
+ const ratchets = [];
237
+ const dir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
238
+ if (dir && (await exists(join(CWD, dir)))) {
239
+ const { readdir } = await import("node:fs/promises");
240
+ for (const f of (await readdir(join(CWD, dir))).filter((n) => n.endsWith(".txt")).sort()) {
241
+ const body = await readFile(join(CWD, dir, f), "utf8");
242
+ const count = body.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")).length;
243
+ ratchets.push({ name: f.replace(/\.txt$/, ""), count });
244
+ }
245
+ }
246
+
247
+ // Свод читается ЦЕЛИКОМ и дословно: пересказ был бы третьим списком рядом с двумя.
248
+ let fullPart = null;
249
+ if (full) {
250
+ const rows = commandRows(L).map((r) => ({
251
+ cmd: `${portableSelf()} ${r.name}${r.args ? ` ${r.args}` : ""}`,
252
+ text: r.text,
253
+ }));
254
+ let text = "";
255
+ if (rules !== null) { try { text = await readFile(join(CWD, entry), "utf8"); } catch { text = ""; } }
256
+ fullPart = { entry, rows, text };
257
+ }
258
+
259
+ console.log(contextBlock({
260
+ entry, entryExists: rules !== null, level, rules, run, ratchets, full: fullPart,
261
+ }).join("\n"));
262
+ }
263
+
264
+ export { cmdContext, contextBlock, parseLastRun, countArbiters, withHook, hasOurHook, portableSelf };