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.
- package/README.md +135 -9
- package/README.ru.md +167 -23
- package/kit/docs/ai/project-baseline.md +14 -0
- package/kit/docs/ready-made-rules.md +103 -0
- package/kit/gates/_skip.sh +61 -1
- package/kit/gates/ci-not-hijackable/README.md +56 -0
- package/kit/gates/ci-not-hijackable/check.sh +73 -0
- package/kit/gates/ci-not-hijackable/gate.yml +19 -0
- package/kit/gates/ci-not-hijackable/green/.github/workflows/triage.yml +19 -0
- package/kit/gates/ci-not-hijackable/red/.github/workflows/triage.yml +18 -0
- package/kit/gates/color-from-token/check.sh +10 -2
- package/kit/gates/color-from-token/green/Button.tsx +2 -0
- package/kit/gates/complexity-limit/check.sh +6 -7
- package/kit/gates/duplicate-code/check.sh +5 -1
- package/kit/gates/entry-links-exist/check.sh +4 -1
- package/kit/gates/entry-links-exist/green/AGENTS.md +2 -0
- package/kit/gates/file-size-limit/check.sh +1 -1
- package/kit/gates/lesson-has-outcome/check.sh +5 -1
- package/kit/gates/mcp-server-resolves/README.md +62 -0
- package/kit/gates/mcp-server-resolves/check.sh +110 -0
- package/kit/gates/mcp-server-resolves/gate.yml +18 -0
- package/kit/gates/mcp-server-resolves/green/.mcp.json +20 -0
- package/kit/gates/mcp-server-resolves/red/.mcp.json +16 -0
- package/kit/gates/secrets-not-in-code/check.sh +16 -3
- package/kit/gates/secrets-not-in-code/green/testdata/certificate/key.pem +3 -0
- package/kit/gates/todo-without-task/check.sh +1 -1
- package/kit/gates/todo-without-task/green/app.py +1 -0
- package/llms.txt +38 -2
- package/package.json +2 -3
- package/tool/commands/context.mjs +264 -0
- package/tool/commands/doctor.mjs +104 -28
- package/tool/commands/learn.mjs +159 -0
- package/tool/commands/project.mjs +19 -2
- package/tool/commands/prove.mjs +1 -0
- package/tool/commands/report.mjs +33 -1
- package/tool/commands/vitals.mjs +159 -0
- package/tool/i18n/en-docs.mjs +125 -1
- package/tool/i18n/en.mjs +39 -36
- package/tool/i18n/index.mjs +36 -3
- package/tool/i18n/ru-docs.mjs +127 -1
- package/tool/i18n/ru.mjs +39 -36
- package/tool/lib/banner.mjs +59 -0
- package/tool/lib/brief.mjs +192 -0
- package/tool/lib/core.mjs +32 -1
- package/tool/lib/evidence.mjs +124 -0
- package/tool/lib/manifest.mjs +173 -15
- package/tool/lib/prove.mjs +24 -2
- package/tool/lib/repo.mjs +31 -1
- package/tool/lib/scope.mjs +10 -1
- package/tool/lib/templates.mjs +1 -0
- package/tool/program.mjs +45 -23
- package/tool/selfcheck/smoke.sh +592 -3
- package/tool/selfcheck/units-banner.mjs +65 -0
- package/tool/selfcheck/units-brief.mjs +97 -0
- package/tool/selfcheck/units-context.mjs +188 -0
- package/tool/selfcheck/units-evidence.mjs +83 -0
- package/tool/selfcheck/units-learn.mjs +88 -0
- package/tool/selfcheck/units-level.mjs +211 -3
- package/tool/selfcheck/units-repo.mjs +134 -0
- package/tool/selfcheck/units-vitals.mjs +62 -0
- 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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 " почини: убери значение из файла, положи его в переменную окружения и отзови старый ключ."
|
|
@@ -21,7 +21,7 @@ fi
|
|
|
21
21
|
# названием и на собственном шаблоне поиска — на том, что дефектом не является.
|
|
22
22
|
# Сами эти слова здесь не пишем: гейт нашёл бы себя. Проверено — находил.
|
|
23
23
|
HITS=$(grep -rnE $(skip_grep "$DIR") $(include_code) \
|
|
24
|
-
'(#|//|/\*|--|<!--)[^"'"'"']*(^|[^A-Za-
|
|
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
|
-
-
|
|
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,
|
|
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.
|
|
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
|
|
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 };
|