agent-quality-kit 0.4.2 → 0.6.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 (76) hide show
  1. package/README.md +136 -12
  2. package/README.ru.md +119 -11
  3. package/kit/docs/ready-made-rules.md +40 -0
  4. package/kit/gates/README.md +35 -0
  5. package/kit/gates/_native.sh +18 -2
  6. package/kit/gates/_skip.sh +18 -0
  7. package/kit/gates/ci-actually-fails/README.md +42 -0
  8. package/kit/gates/ci-actually-fails/check.sh +93 -0
  9. package/kit/gates/ci-actually-fails/gate.yml +14 -0
  10. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +14 -0
  11. package/kit/gates/ci-actually-fails/red/.github/workflows/ci.yml +12 -0
  12. package/kit/gates/color-from-token/README.md +52 -0
  13. package/kit/gates/color-from-token/check.sh +69 -0
  14. package/kit/gates/color-from-token/gate.yml +15 -0
  15. package/kit/gates/color-from-token/green/Button.tsx +4 -0
  16. package/kit/gates/color-from-token/green/Panel.vue +4 -0
  17. package/kit/gates/color-from-token/green/card.css +5 -0
  18. package/kit/gates/color-from-token/green/notes.md +2 -0
  19. package/kit/gates/color-from-token/green/tokens.css +7 -0
  20. package/kit/gates/color-from-token/red/Button.tsx +4 -0
  21. package/kit/gates/color-from-token/red/Panel.vue +4 -0
  22. package/kit/gates/color-from-token/red/card.css +5 -0
  23. package/kit/gates/commit-explains-itself/check.sh +25 -2
  24. package/kit/gates/duplicate-code/check.sh +13 -4
  25. package/kit/gates/duplicate-code/gate.yml +11 -2
  26. package/kit/gates/gate-has-samples/check.sh +9 -3
  27. package/kit/gates/gate-not-weakened/README.md +54 -0
  28. package/kit/gates/gate-not-weakened/check.sh +72 -0
  29. package/kit/gates/gate-not-weakened/gate.yml +15 -0
  30. package/kit/gates/gate-not-weakened/green/checkout.ts +8 -0
  31. package/kit/gates/gate-not-weakened/green/payments.py +6 -0
  32. package/kit/gates/gate-not-weakened/green/release.sh +2 -0
  33. package/kit/gates/gate-not-weakened/red/checkout.ts +9 -0
  34. package/kit/gates/gate-not-weakened/red/payments.py +6 -0
  35. package/kit/gates/gate-not-weakened/red/release.sh +2 -0
  36. package/kit/gates/gates-are-runnable/check.sh +7 -1
  37. package/kit/gates/gates-run-in-ci/check.sh +7 -1
  38. package/kit/gates/lesson-has-outcome/check.sh +6 -1
  39. package/kit/gates/promise-has-gate/README.md +50 -0
  40. package/kit/gates/promise-has-gate/check.sh +88 -0
  41. package/kit/gates/promise-has-gate/gate.yml +14 -0
  42. package/kit/gates/promise-has-gate/green/.aqk.yml +6 -0
  43. package/kit/gates/promise-has-gate/green/AGENTS.md +7 -0
  44. package/kit/gates/promise-has-gate/red/.aqk.yml +6 -0
  45. package/kit/gates/promise-has-gate/red/AGENTS.md +7 -0
  46. package/kit/gates/test-has-assertion/README.md +47 -0
  47. package/kit/gates/test-has-assertion/check.sh +194 -0
  48. package/kit/gates/test-has-assertion/gate.yml +15 -0
  49. package/kit/gates/test-has-assertion/green/checkout.test.ts +9 -0
  50. package/kit/gates/test-has-assertion/green/test_billing.py +17 -0
  51. package/kit/gates/test-has-assertion/red/checkout.test.ts +8 -0
  52. package/kit/gates/test-has-assertion/red/test_billing.py +14 -0
  53. package/kit/ratchet/ratchet.sh +9 -2
  54. package/kit/rules/general.md +14 -0
  55. package/llms.txt +58 -0
  56. package/package.json +4 -2
  57. package/tool/commands/doctor.mjs +106 -11
  58. package/tool/commands/gates.mjs +18 -3
  59. package/tool/commands/project.mjs +13 -4
  60. package/tool/commands/report.mjs +2 -2
  61. package/tool/i18n/en.mjs +55 -0
  62. package/tool/i18n/ru.mjs +61 -0
  63. package/tool/i18n/templates-en.mjs +9 -9
  64. package/tool/i18n/templates-ru.mjs +9 -9
  65. package/tool/lib/baseline.mjs +87 -0
  66. package/tool/lib/core.mjs +10 -2
  67. package/tool/lib/manifest.mjs +69 -2
  68. package/tool/lib/repo.mjs +7 -0
  69. package/tool/lib/scope.mjs +96 -0
  70. package/tool/program.mjs +1 -0
  71. package/tool/selfcheck/gates.sh +29 -5
  72. package/tool/selfcheck/lifecycle.mjs +29 -0
  73. package/tool/selfcheck/mutation.sh +95 -0
  74. package/tool/selfcheck/smoke.sh +269 -8
  75. package/tool/selfcheck/syntax.sh +9 -1
  76. package/tool/selfcheck/units.mjs +160 -1
@@ -17,7 +17,12 @@ REG="${1:-}"; shift || true
17
17
 
18
18
  # Ключ нарушения обязан переживать правку соседних строк, иначе сдвиг на строку читается
19
19
  # как новое нарушение. Поэтому номер строки из ключа убирается.
20
- keys() { grep -E '^[^[:space:]].*:' | sed -E 's/:[0-9]+:/:/' | sort -u; }
20
+ #
21
+ # Два шаблона, а не один: у гейта duplicate-code строка несёт ДВА места — «a:171 и b:188: …»,
22
+ # и за номером первого идёт не двоеточие, а пробел. Шаблон с двоеточием убирал номер только
23
+ # у второго файла пары, и сдвиг кода в первом читался как новое нарушение — ровно то, от чего
24
+ # храповик защищает.
25
+ keys() { grep -E '^[^[:space:]].*:' | sed -E 's/:[0-9]+:/:/g; s/:[0-9]+( |$)/:\1/g' | LC_ALL=C sort -u; }
21
26
 
22
27
  OUT="$("$@" 2>&1)"
23
28
  NOW="$(printf '%s\n' "$OUT" | keys)"
@@ -26,7 +31,9 @@ if [ ! -f "$REG" ]; then
26
31
  echo "нет реестра $REG — сначала: aqk ratchet <гейт>"
27
32
  exit 2
28
33
  fi
29
- WAS="$(grep -vE '^\s*(#|$)' "$REG" | sort -u)"
34
+ # Та же сортировка, что в keys(): comm сравнивает построчно и требует, чтобы оба входа были
35
+ # упорядочены одинаково. Разная локаль у двух сторон — это тихо разъехавшееся сравнение.
36
+ WAS="$(grep -vE '^\s*(#|$)' "$REG" | LC_ALL=C sort -u)"
30
37
 
31
38
  NEW="$(comm -23 <(printf '%s\n' "$NOW") <(printf '%s\n' "$WAS"))"
32
39
  GONE="$(comm -13 <(printf '%s\n' "$NOW") <(printf '%s\n' "$WAS"))"
@@ -7,6 +7,20 @@
7
7
  - **Ни одного тихого отказа.** Ошибка обработана и записана либо проброшена.
8
8
  - **Границы явные.** На стыках — проверка входа, а не доверие.
9
9
 
10
+ ## Сомнение — повод посмотреть наружу
11
+
12
+ Агент отвечает уверенно всегда: и когда знает, и когда достраивает по памяти. Со стороны это
13
+ неотличимо, а цена разная. Поэтому четыре случая обязаны кончаться поиском, а не догадкой:
14
+
15
+ | Случай | Что происходит без поиска |
16
+ |---|---|
17
+ | не знаешь, как принято **сейчас** | пишется то, что было принято на момент обучения |
18
+ | не знаешь, есть ли **готовое** | пишется свой велосипед, который потом чинить самому |
19
+ | собираешься написать распространённую вещь | половина работы уже сделана кем-то и проверена |
20
+ | помнишь ответ, но **из обучения, а не из проверки** | вспомненный API мог быть переименован или убран |
21
+
22
+ Правило дешевле, чем кажется: поиск стоит минуту, а неверная догадка — правку, ревью и шишку.
23
+
10
24
  ## Запрещено в готовом коде
11
25
 
12
26
  - отладочная печать;
package/llms.txt ADDED
@@ -0,0 +1,58 @@
1
+ # AQK — Agent Quality Kit
2
+
3
+ > A standard and a CLI that check whether a repository is ready to have its code written by AI
4
+ > coding agents. Every rule the project promises to follow becomes a command with an exit code,
5
+ > so a machine holds the promise instead of somebody's attention. Reports a level from AQK-0 to
6
+ > AQK-3, computed by a run — never by a questionnaire and never by a model's opinion.
7
+
8
+ Vendor-neutral: works with any coding agent (Claude Code, Codex, Cursor, Gemini CLI, GitHub
9
+ Copilot, Windsurf, Aider, OpenCode) and with no AI at all. It calls no vendor API and needs no
10
+ key. Stack-neutral: portable checks are plain `sh`; where the project already has a native tool
11
+ (ruff, eslint, knip, jscpd) the check uses it instead and says so when it falls back.
12
+
13
+ Zero runtime dependencies. Node 18+ and an `sh` shell. MIT.
14
+
15
+ ## Use it
16
+
17
+ - Check an existing repository: `npx agent-quality-kit doctor` (read-only: writes nothing, sends
18
+ nothing)
19
+ - Start a new one: `npx agent-quality-kit start`
20
+ - Install one guard from the catalogue: `npx agent-quality-kit add <name>`
21
+ - Check the minimum a project needs before agents can be handed the work:
22
+ `npx agent-quality-kit doctor --baseline` (14 of 50 points confirmed by a run, ecosystem-neutral;
23
+ the other 36 are named as a number, not hidden)
24
+ - Fail a pipeline below a level or on a failed gate: `npx agent-quality-kit doctor --run --min 1`
25
+ - Show only what a diff introduced, so a legacy repo is usable from day one: `doctor --run --since main`
26
+ - Exit codes: 0 pass, 1 below the level or a gate failed
27
+ - As a pre-commit hook: `repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit` with
28
+ `id: aqk` (blocking), `aqk-doctor` (read-only) or `aqk-baseline`. pre-commit installs the
29
+ package itself; there are no dependencies to pull in.
30
+ - As a GitHub Action: `uses: arsen-ask-lx/Agent_Quality_Kit@v0.4.2` with `min: 1`
31
+ (https://github.com/marketplace/actions/agent-quality-kit-aqk)
32
+
33
+ ## What makes it different
34
+
35
+ - The level is computed by running what the repository declares — not from a self-assessment
36
+ questionnaire, not from the presence of a config file, not from a language model's judgement.
37
+ - Every guard must ship a red and a green sample, and a machine checks that the guard goes red on
38
+ the red one and stays quiet on the green one. A check that cannot fail is indistinguishable
39
+ from a check that works; this is the only place that requirement is enforced.
40
+ - An entry enters the catalogue only when it names a real failure it caught, recorded in the
41
+ bruise journal.
42
+
43
+ ## Files it reads and writes
44
+
45
+ - `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
47
+ - `.aqkignore` — paths the scanning checks must not read (brought-in code, vendored, generated)
48
+
49
+ ## Documentation
50
+
51
+ - [README](README.md): what it is, how to install, the catalogue, the badge, CI
52
+ - [SPEC.md](SPEC.md): the standard itself — levels, manifest, how an entry is accepted
53
+ - [Project baseline](kit/docs/ai/project-baseline.md): what a project needs before agents can be
54
+ handed the work, in plain words, independent of language and tooling
55
+ - [The gate catalogue](kit/gates/README.md): what a gate is and the bar an entry must clear
56
+ - [The bruise journal](incidents/README.md): every defect found, and what was made of it
57
+ - [CONTRIBUTING.md](CONTRIBUTING.md): how to bring a gate
58
+ - [SECURITY.md](SECURITY.md): reporting a vulnerability, and the trust model of the manifest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-quality-kit",
3
- "version": "0.4.2",
3
+ "version": "0.6.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": {
@@ -11,6 +11,7 @@
11
11
  "kit",
12
12
  "README.md",
13
13
  "README.ru.md",
14
+ "llms.txt",
14
15
  "LICENSE"
15
16
  ],
16
17
  "engines": {
@@ -42,7 +43,8 @@
42
43
  ],
43
44
  "knip": {
44
45
  "entry": [
45
- "tool/selfcheck/units.mjs"
46
+ "tool/selfcheck/units.mjs",
47
+ "tool/selfcheck/lifecycle.mjs"
46
48
  ],
47
49
  "project": [
48
50
  "tool/**/*.mjs"
@@ -3,11 +3,48 @@
3
3
  import { readFile, mkdir, writeFile } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
- import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists } from "../lib/core.mjs";
7
- import { readManifest, assessLevel } from "../lib/manifest.mjs";
6
+ import { scopeOutput, changedFiles } from "../lib/scope.mjs";
7
+ import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists, die } from "../lib/core.mjs";
8
+ import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS } from "../lib/manifest.mjs";
8
9
  import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
10
+ import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
9
11
  import { L } from "../i18n/index.mjs";
10
12
 
13
+ // Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
14
+ // место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
15
+ async function reportBaseline(man, facts) {
16
+ const { readdir, readFile } = await import("node:fs/promises");
17
+ let files = [];
18
+ try {
19
+ files = (await readdir(CWD, { withFileTypes: true })).map((d) => d.name);
20
+ } catch { /* пустой список честнее выдуманного: ни один пункт не подтвердится */ }
21
+
22
+ // Файлы зависимостей читаются целиком и склеиваются: трекер ошибок объявляют по-разному в
23
+ // каждой экосистеме, а искать его надо одинаково.
24
+ let depsText = "";
25
+ for (const f of DEP_FILES) {
26
+ if (!files.some((n) => n.toLowerCase() === f)) continue;
27
+ try { depsText += (await readFile(join(CWD, f), "utf8")).toLowerCase() + "\n"; } catch { /* нечитаемый файл — просто не признак */ }
28
+ }
29
+
30
+ const rows = assessBaseline({ files, gateKeys: facts.gateKeys, facts, manifest: man || {}, depsText });
31
+ const okCount = rows.filter((r) => r.ok).length;
32
+
33
+ console.log(c.bold(`\n ${L.baseline.heading}\n`));
34
+ console.log(c.dim(` ${L.baseline.intro(rows.length, BASELINE_TOTAL)}`));
35
+ console.log(c.dim(` ${L.baseline.caveat}\n`));
36
+ for (const r of rows) {
37
+ const mark = r.ok ? c.green("✔") : c.yellow("✘");
38
+ const title = L.baseline.titles[r.key] || r.key;
39
+ console.log(` ${mark} ${String(r.n).padStart(2)}. ${title}`);
40
+ console.log(c.dim(` ${r.ok ? L.baseline.by(r.by) : L.baseline.none}`));
41
+ }
42
+ console.log(
43
+ "\n " + (okCount === rows.length ? c.green(`${okCount}/${rows.length}`) : c.yellow(`${okCount}/${rows.length}`)) +
44
+ c.dim(` · ${L.baseline.eyes(BASELINE_TOTAL - rows.length, "kit/docs/ai/project-baseline.md")}\n`)
45
+ );
46
+ }
47
+
11
48
  async function reportCatalog(man, facts) {
12
49
  const catalog = await readCatalog();
13
50
  if (!catalog.length) return;
@@ -58,10 +95,27 @@ function declaredGates(man) {
58
95
  .filter(([, cmd]) => cmd);
59
96
  }
60
97
 
61
- function runGates(man) {
98
+ // Ссылка, относительно которой сужается вывод: `--since main`, `--since HEAD~5`.
99
+ // Без значения флаг бессмыслен — молча взять умолчание нельзя: «сужено не тем» неотличимо
100
+ // от «не сужено».
101
+ function sinceRef(argv = process.argv) {
102
+ const i = argv.indexOf("--since");
103
+ if (i === -1) return null;
104
+ const v = argv[i + 1];
105
+ return v && !v.startsWith("-") ? v : null;
106
+ }
107
+
108
+ function runGates(man, opts = {}) {
62
109
  const gates = declaredGates(man);
63
110
  if (!gates.length) return { failed: 0, ran: 0, results: [] };
64
111
 
112
+ // Сужение по дифу — договор с человеком, и он должен видеть, ЧТО именно сужено. Пустой диф
113
+ // называется вслух: иначе «все гейты зелёные» означало бы «сравнили не с тем» и читалось бы
114
+ // как успех. Это тот же класс, что и весь стандарт, только внутри нашего флага.
115
+ const scoped = opts.since ? changedFiles(opts.since, CWD) : null;
116
+ if (opts.since && scoped === null) die(L.doctor.sinceBadRef(opts.since));
117
+ if (scoped) console.log(c.dim(`\n ${L.doctor.sinceHeading(opts.since, scoped.size)}`));
118
+
65
119
  console.log(c.bold(`\n ${L.doctor.runHeading}\n`));
66
120
  let failed = 0;
67
121
  const results = [];
@@ -82,8 +136,30 @@ function runGates(man) {
82
136
  console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${cmd}`)}`);
83
137
  results.push({ name, cmd, ok: true, secs });
84
138
  } else {
139
+ let out = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
140
+
141
+ // Сужение до дифа. Три исхода, и все три называются вслух.
142
+ if (scoped) {
143
+ const s = scopeOutput(out, scoped);
144
+ if (!s.scopable) {
145
+ // Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
146
+ // выдать провал за тишину; остаётся красным, и причина названа.
147
+ console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${L.doctor.notScopable}`)}`);
148
+ failed++;
149
+ results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable });
150
+ continue;
151
+ }
152
+ if (s.findings === 0) {
153
+ // Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
154
+ // «всё хорошо» здесь было бы неправдой.
155
+ console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
156
+ results.push({ name, cmd, ok: true, secs, scopedAway: out.length });
157
+ continue;
158
+ }
159
+ out = s.kept;
160
+ }
161
+
85
162
  failed++;
86
- const out = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
87
163
  console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${secs}s · ${cmd}`)}`);
88
164
  for (const line of out.slice(0, 3)) console.log(c.dim(` ${line.slice(0, 100)}`));
89
165
  if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
@@ -158,6 +234,15 @@ async function cmdDoctor() {
158
234
  }
159
235
 
160
236
  const man = await readManifest();
237
+
238
+ // Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
239
+ // Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
240
+ const unknown = unknownKeys(man);
241
+ if (unknown.length) {
242
+ console.log(c.yellow(`\n ${L.doctor.manifestUnknown(unknown)}`));
243
+ console.log(c.dim(` ${L.doctor.manifestKnown(KNOWN_KEYS)}\n`));
244
+ }
245
+
161
246
  const { reached, steps } = await assessLevel(man);
162
247
 
163
248
  console.log(c.bold(`\n ${L.doctor.levelHeading}\n`));
@@ -190,15 +275,21 @@ async function cmdDoctor() {
190
275
  }
191
276
 
192
277
  const facts = await detectFacts(man);
278
+ if (process.argv.includes("--baseline")) {
279
+ await reportBaseline(man, facts);
280
+ process.exit(0);
281
+ }
193
282
  await reportCatalog(man, facts);
194
283
 
195
284
  // «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
196
285
  const wantRun = process.argv.includes("--run");
197
286
  const gates = declaredGates(man);
198
287
  let gateFailed = 0;
288
+ let failedNames = [];
199
289
  if (wantRun) {
200
- const run = runGates(man);
290
+ const run = runGates(man, { since: sinceRef() });
201
291
  gateFailed = run.failed;
292
+ failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
202
293
  await writeRunReport({ version, reached, results: run.results });
203
294
  } else if (gates.length) {
204
295
  console.log(
@@ -211,12 +302,16 @@ async function cmdDoctor() {
211
302
  const minIdx = process.argv.indexOf("--min");
212
303
  const min = minIdx > -1 ? Number(process.argv[minIdx + 1]) : null;
213
304
  if (min !== null) {
214
- const pass = reached >= min && gateFailed === 0;
215
- console.log(
216
- pass
217
- ? c.green(` ${L.doctor.thresholdPass(min)}\n`)
218
- : c.red(` ${L.doctor.thresholdFail(min, reached < 0 ? L.doctor.levelNone : reached)}\n`)
219
- );
305
+ const levelOk = reached >= min;
306
+ const pass = levelOk && gateFailed === 0;
307
+ // Две разные развилки, и сообщение обязано их различать. «Порог не пройден: сейчас AQK-1»
308
+ // при пороге AQK-1 противоречит само себе и отправляет чинить манифест, когда падал гейт.
309
+ const now = reached < 0 ? L.doctor.levelNone : reached;
310
+ let line;
311
+ if (pass) line = c.green(` ${L.doctor.thresholdPass(min)}\n`);
312
+ else if (!levelOk) line = c.red(` ${L.doctor.thresholdFail(min, now)}\n`);
313
+ else line = c.red(` ${L.doctor.thresholdGateFail(min, now, failedNames)}\n`);
314
+ console.log(line);
220
315
  process.exit(pass ? 0 : 1);
221
316
  }
222
317
  process.exit(missing || reached < 0 || gateFailed ? 1 : 0);
@@ -8,7 +8,7 @@ import {
8
8
  CWD, PKG_ROOT, GATES_SRC, PROJECT_GATES, RATCHET_DIR, RATCHET_LIB, MANIFEST, SELF, c, exists, die,
9
9
  copyDir,
10
10
  } from "../lib/core.mjs";
11
- import { parseManifest, readManifest, manifestWithGate } from "../lib/manifest.mjs";
11
+ import { parseManifest, readManifest, manifestWithGate, entryLifecycle } from "../lib/manifest.mjs";
12
12
  import {
13
13
  detectFacts, readCatalog, pickRecipe, triggerVerdict, stems, overlap, matchCatalog,
14
14
  } from "../lib/repo.mjs";
@@ -28,6 +28,13 @@ async function installGate(slug, man, facts) {
28
28
  if (!(await exists(src))) die(L.add.noSuchGate(slug, `${SELF} doctor`));
29
29
 
30
30
  const rec = { slug, ...parseManifest(await readFile(join(src, "gate.yml"), "utf8")) };
31
+
32
+ // Выведенную запись не ставим. Молча пропустить нельзя — человек пришёл за конкретной
33
+ // проверкой и обязан узнать, кто её заменил; ответ «а что теперь» и есть цена вывода.
34
+ // Проверка ДО копирования: иначе в проекте остаётся папка гейта, которого не будет в манифесте.
35
+ const life = entryLifecycle(rec);
36
+ if (life.state === "deprecated") return { rec, cmd: null, copied: [], declared: false, why: null, retired: life.supersededBy || "" };
37
+
31
38
  const dst = join(CWD, PROJECT_GATES, slug);
32
39
  await mkdir(dst, { recursive: true });
33
40
  const copied = await copyDir(src, dst, { force: false });
@@ -44,7 +51,13 @@ async function installGate(slug, man, facts) {
44
51
  let cmd = picked
45
52
  .replace(/\{gate\}/g, `${PROJECT_GATES}/${slug}`)
46
53
  .replace(/\{dir\}/g, ".");
47
- if (!cmd) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
54
+ // Отказ, а не смерть. `add` ставит одну запись — там смерть уместна и остаётся в вызывающем.
55
+ // `start` ставит пачку, и падение на одной записи оставляло проект с тремя сторожами вместо
56
+ // двенадцати, без единого слова про остальные девять. Найдено прогоном на Windows: там нет
57
+ // ни `ruff`, ни `vulture`, и установка обрывалась на записи `dead-code`, которой нужен
58
+ // настоящий инструмент. Отсутствие сигнала неотличимо от успеха — здесь оно было внутри
59
+ // самой установки.
60
+ if (!cmd) return { rec, cmd: null, copied, declared: false, why: null, noRecipe: true };
48
61
 
49
62
  // Родной инструмент не знает про наши образцы и выдаёт их как находки — в любом проекте,
50
63
  // куда поставили гейты. Заворачиваем его в общий фильтр. Переносимая проверка фильтрует
@@ -81,7 +94,9 @@ async function cmdAdd(args) {
81
94
  console.log(c.dim(` ${L.add.installAnyway}\n`));
82
95
  }
83
96
 
84
- const { cmd, copied, declared, why } = await installGate(slug, man, facts);
97
+ const { cmd, copied, declared, why, noRecipe, retired } = await installGate(slug, man, facts);
98
+ if (retired !== undefined) die(L.lifecycle.installDeprecated(slug, retired ? `${SELF} add ${retired}` : "—"));
99
+ if (noRecipe) die(L.add.noRecipe(slug, [...facts.langs].join("/") || L.add.thisStack));
85
100
 
86
101
  console.log(c.bold(`\naqk add ${slug}\n`));
87
102
  console.log(` ${c.green("✔")} ${PROJECT_GATES}/${slug}/ ${c.dim(L.add.copied(copied.length))}`);
@@ -6,8 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { join, dirname, relative } from "node:path";
7
7
  import {
8
8
  CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
9
- copyDir, writeIfAbsent, FEEDBACK_MARK,
10
- } from "../lib/core.mjs";
9
+ copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
11
10
  import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
12
11
  import { readManifest } from "../lib/manifest.mjs";
13
12
  import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
@@ -199,7 +198,7 @@ async function cmdBlob() {
199
198
  await walk(dir);
200
199
 
201
200
  for (const full of found) {
202
- out += `\n\n${"=".repeat(78)}\n<!-- ${L.blob.source(relative(PKG_ROOT, full))} -->\n${"=".repeat(78)}\n\n`;
201
+ out += `\n\n${"=".repeat(78)}\n<!-- ${L.blob.source(docPath(PKG_ROOT, full))} -->\n${"=".repeat(78)}\n\n`;
203
202
  // Ссылки на соседние файлы в склейке ведут в никуда: соседей рядом больше нет,
204
203
  // все они внутри этого же текста. Оставляем подпись, снимаем разметку.
205
204
  const body = (await readFile(full, "utf8")).replace(
@@ -268,7 +267,17 @@ async function cmdStart(args) {
268
267
  if (declared.has(rec.slug)) continue;
269
268
  const v = triggerVerdict(rec, facts0);
270
269
  if (!v.applies) { skipped.push([rec.slug, v.why]); continue; }
271
- const { cmd } = await installGate(rec.slug, man, facts0);
270
+ const { cmd, noRecipe, retired } = await installGate(rec.slug, man, facts0);
271
+ // Выведенная запись в пачку не идёт, но и молчать о ней нельзя: она попадает в тот же
272
+ // список пропущенного с названным преемником.
273
+ if (retired !== undefined) {
274
+ skipped.push([rec.slug, L.lifecycle.installDeprecated(rec.slug, retired || "—")]);
275
+ declared.add(rec.slug); continue;
276
+ }
277
+ // Записи, которой нужен инструмент, а его на машине нет, здесь не место — но и вся
278
+ // установка из-за неё останавливаться не должна. Причина называется вслух и попадает
279
+ // в тот же список пропущенного, что и записи, не подошедшие по триггеру.
280
+ if (noRecipe) { skipped.push([rec.slug, L.start.noRecipeHere]); declared.add(rec.slug); continue; }
272
281
  put.push([rec.slug, cmd, rec.intent || ""]);
273
282
  declared.add(rec.slug);
274
283
  added++;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { mkdir, writeFile, readdir, readFile } from "node:fs/promises";
17
17
  import { join, relative } from "node:path";
18
- import { CWD, TARGET_DIR, SELF, c, exists } from "../lib/core.mjs";
18
+ import { CWD, TARGET_DIR, SELF, c, exists, docPath } from "../lib/core.mjs";
19
19
  import { readManifest, assessLevel } from "../lib/manifest.mjs";
20
20
  import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
21
21
  import { runGates, declaredGates } from "./doctor.mjs";
@@ -59,7 +59,7 @@ async function findDoc(name) {
59
59
  for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
60
60
  const full = join(dir, e.name);
61
61
  if (e.isDirectory()) { const hit = await walk(full); if (hit) return hit; }
62
- else if (e.name === name) return relative(CWD, full);
62
+ else if (e.name === name) return docPath(CWD, full);
63
63
  }
64
64
  return null;
65
65
  };
package/tool/i18n/en.mjs CHANGED
@@ -15,6 +15,7 @@ export const en = {
15
15
  start: "no code yet: day-zero guards and the order of work",
16
16
  doctor: "check what is laid out and what is missing",
17
17
  doctorRun: "and also run the declared gates",
18
+ doctorSince: "the same, but show only what the diff against a ref introduced",
18
19
  add: "install a gate from the catalogue into the project",
19
20
  find: "is there already such a gate — matched by intent",
20
21
  why: "a bug slipped through — why did no guard catch it",
@@ -66,6 +67,10 @@ export const en = {
66
67
  totalTodo: (n) => `applicable but not installed ${n}`,
67
68
  totalSkip: (n) => `hidden ${n}`,
68
69
 
70
+ sinceHeading: (ref, n) => `narrowed to the diff against ${ref}: ${n} files touched`,
71
+ sinceBadRef: (ref) => `cannot compare against "${ref}": no such ref, or this is not a git repository`,
72
+ notScopable: "output carries no paths — cannot be narrowed by diff, left red",
73
+ outsideDiff: (n) => `findings exist, but outside the diff (${n})`,
69
74
  runHeading: "Running the declared gates",
70
75
  timeout: "did not finish within 5 minutes",
71
76
  exitCode: (code) => `exit ${code}`,
@@ -73,8 +78,44 @@ export const en = {
73
78
  declaredNotRun: (n) => `${n} gates declared, but never run.`,
74
79
  declaredNotRunWhy: (cmd) => ` "declared" and "works" are different claims: ${cmd}`,
75
80
 
81
+ manifestUnknown: (keys) =>
82
+ `The manifest has fields the standard does not know: ${keys.join(", ")}. Looks like a typo — ` +
83
+ `such a field is silently read as absent, and the verdict comes out wrong.`,
84
+ manifestKnown: (keys) => `Manifest fields: ${keys.join(", ")}`,
76
85
  thresholdPass: (min) => `Threshold AQK-${min} passed.`,
77
86
  thresholdFail: (min, now) => `Threshold AQK-${min} NOT passed: currently AQK-${now}.`,
87
+ thresholdGateFail: (min, now, names) =>
88
+ `Threshold AQK-${min} passed (currently AQK-${now}), but a gate failed: ${names.join(", ")}.`,
89
+ },
90
+
91
+ baseline: {
92
+ heading: "The minimum a project needs",
93
+ intro: (checked, total) =>
94
+ `a machine confirms ${checked} of ${total} points; the rest are for your eyes, in the guide`,
95
+ eyes: (n, path) => `${n} points a machine cannot check — they live in ${path}`,
96
+ caveat: "presence is what gets checked, not whether it works: \"a linter is configured\" and \"a linter catches things\" are different claims",
97
+ by: (b) =>
98
+ "proven by: " +
99
+ ({ gate: `gate ${b.value}`, fact: `repository scan: ${b.value}`,
100
+ manifest: `field ${b.value} in the manifest`, dep: `dependency ${b.value}`,
101
+ file: b.value }[b.kind] || b.value),
102
+ none: "no conventional marker — check by eye, it may be done another way",
103
+ titles: {
104
+ oneCommand: "one command brings the whole project up",
105
+ lockfile: "exact versions pinned in a lockfile",
106
+ sameEnv: "the environment is the same for everyone and in CI",
107
+ formatter: "formatting is uniform and applied automatically",
108
+ linter: "a linter is configured",
109
+ types: "type checking exists",
110
+ secretScan: "secret scanning",
111
+ fileSize: "a file size limit",
112
+ ownInvariants: "the project's own invariants",
113
+ tests: "arbiters of correctness: tests exist",
114
+ pipeline: "a pipeline exists",
115
+ errorTracker: "errors are collected separately from logs",
116
+ machineReadable: "the project is machine-readable",
117
+ rulesInRepo: "rules live in the repository and are versioned",
118
+ },
78
119
  },
79
120
 
80
121
  trigger: {
@@ -91,6 +132,7 @@ export const en = {
91
132
  has_deps: ["no dependency file in sight", "dependencies are declared"],
92
133
  has_tests: ["no tests in sight", "tests exist"],
93
134
  has_env: ["no environment file", "an environment file exists"],
135
+ has_ui: ["no stylesheets or UI components in sight", "a UI exists: stylesheets or components"],
94
136
  },
95
137
  },
96
138
 
@@ -99,6 +141,18 @@ export const en = {
99
141
  none: "no recipe described",
100
142
  },
101
143
 
144
+ // Entry maturity. Computed from the entry's proof; it cannot be declared — see
145
+ // entryLifecycle in tool/lib/manifest.mjs.
146
+ lifecycle: {
147
+ stable: "proven by an incident from the journal",
148
+ experimental: "proof is not from the journal — the entry is provisional",
149
+ deprecated: "retired",
150
+ unknownReplacement: (v) => `superseded_by: ${v} — no such entry in the catalogue`,
151
+ noReplacement: "lifecycle: deprecated without superseded_by — no replacement is named",
152
+ notDeclarable: (v) => `lifecycle: ${v} cannot be declared — maturity is computed from the proof`,
153
+ unknown: (v) => `lifecycle: ${v} — no such state; only deprecated is declared`,
154
+ installDeprecated: (slug, by) => `entry ${slug} is retired, ${by} replaces it`,
155
+ },
102
156
  manifest: {
103
157
  noGatesBlock: "no gates: block in .aqk.yml",
104
158
  alreadyDeclared: "already declared",
@@ -299,6 +353,7 @@ export const en = {
299
353
  },
300
354
 
301
355
  start: {
356
+ noRecipeHere: "needs a tool that is not on this machine",
302
357
  initFailed: (cmd) => `Could not lay out the kit. Start with ${cmd}`,
303
358
  tooManyFiles: (n) => `This repository already has ${n} code files — that is a different scenario.`,
304
359
  useDoctor: (cmd) => `${cmd} will inspect what is here and split the entries into three lists:`,
package/tool/i18n/ru.mjs CHANGED
@@ -16,6 +16,7 @@ export const ru = {
16
16
  start: "кода ещё нет: сторожа дня 0 и порядок работы",
17
17
  doctor: "проверить, что разложено и чего не хватает",
18
18
  doctorRun: "ещё и запустить объявленные гейты",
19
+ doctorSince: "то же, но показать только то, что внёс диф относительно ссылки",
19
20
  add: "поставить гейт из каталога в проект",
20
21
  find: "есть ли уже такой гейт — сверка по намерению",
21
22
  why: "поймал ошибку — почему её не поймал сторож",
@@ -67,6 +68,10 @@ export const ru = {
67
68
  totalTodo: (n) => `применимо но не поставлено ${n}`,
68
69
  totalSkip: (n) => `скрыто ${n}`,
69
70
 
71
+ sinceHeading: (ref, n) => `сужено до дифа относительно ${ref}: файлов затронуто ${n}`,
72
+ sinceBadRef: (ref) => `не могу сравнить с «${ref}»: такой ссылки нет или это не репозиторий git`,
73
+ notScopable: "вывод без путей — дифом не сужается, оставлен красным",
74
+ outsideDiff: (n) => `находки есть, но вне дифа (${n})`,
70
75
  runHeading: "Прогон объявленных гейтов",
71
76
  timeout: "не уложился в 5 минут",
72
77
  exitCode: (code) => `код ${code}`,
@@ -74,8 +79,49 @@ export const ru = {
74
79
  declaredNotRun: (n) => `${n} гейтов объявлено, но не запускалось.`,
75
80
  declaredNotRunWhy: (cmd) => ` «Объявлен» и «работает» — разные утверждения: ${cmd}`,
76
81
 
82
+ manifestUnknown: (keys) =>
83
+ `В манифесте поля, которых стандарт не знает: ${keys.join(", ")}. Похоже на опечатку — ` +
84
+ `такое поле молча читается как отсутствующее, и вердикт выходит неверным.`,
85
+ manifestKnown: (keys) => `Поля манифеста: ${keys.join(", ")}`,
77
86
  thresholdPass: (min) => `Порог AQK-${min} пройден.`,
78
87
  thresholdFail: (min, now) => `Порог AQK-${min} НЕ пройден: сейчас AQK-${now}.`,
88
+ // Ступень взята, но прогон красный — это другое утверждение, и виновника называем сразу:
89
+ // иначе его ищут глазами выше по логу конвейера.
90
+ thresholdGateFail: (min, now, names) =>
91
+ `Порог AQK-${min} пройден (сейчас AQK-${now}), но упал гейт: ${names.join(", ")}.`,
92
+ },
93
+
94
+ baseline: {
95
+ heading: "Обязательный минимум проекта",
96
+ intro: (checked, total) =>
97
+ `машина подтверждает ${checked} пунктов из ${total}; остальные — глазами, по методичке`,
98
+ eyes: (n, path) => `${n} пунктов машина проверить не может — они в ${path}`,
99
+ caveat: "проверяется НАЛИЧИЕ признака, а не то, что он работает: «линтер настроен» и «линтер ловит» — разные утверждения",
100
+ by: (b) =>
101
+ "подтверждено: " +
102
+ ({ gate: `гейт ${b.value}`, fact: `осмотр репозитория: ${b.value}`,
103
+ manifest: `поле ${b.value} в манифесте`, dep: `зависимость ${b.value}`,
104
+ file: b.value }[b.kind] || b.value),
105
+ // «Не найдено» и «нет» — разные утверждения. Признак ищется по общепринятым именам; проект
106
+ // может делать то же самое своим способом, и тогда пункт остаётся человеку, а не считается
107
+ // проваленным. Иначе прибор врёт на первом же нестандартном проекте — на нас самих и врал.
108
+ none: "общепринятого признака нет — проверь глазами, возможно сделано иначе",
109
+ titles: {
110
+ oneCommand: "одна команда поднимает проект целиком",
111
+ lockfile: "точные версии записаны в файл-замок",
112
+ sameEnv: "среда одинакова у всех и в конвейере",
113
+ formatter: "форматирование единое и применяется автоматически",
114
+ linter: "линтер настроен",
115
+ types: "проверка типов есть",
116
+ secretScan: "поиск секретов",
117
+ fileSize: "ограничение размера файла",
118
+ ownInvariants: "свои инварианты проекта",
119
+ tests: "арбитры правильности: тесты есть",
120
+ pipeline: "конвейер есть",
121
+ errorTracker: "ошибки собираются отдельно от логов",
122
+ machineReadable: "проект читается машиной",
123
+ rulesInRepo: "правила живут в репозитории и версионируются",
124
+ },
79
125
  },
80
126
 
81
127
  trigger: {
@@ -92,6 +138,7 @@ export const ru = {
92
138
  has_deps: ["не видно файла зависимостей", "зависимости объявлены"],
93
139
  has_tests: ["не видно тестов", "тесты есть"],
94
140
  has_env: ["нет файла окружения", "файл окружения есть"],
141
+ has_ui: ["не видно стилей и компонентов интерфейса", "интерфейс есть: стили или компоненты"],
95
142
  },
96
143
  },
97
144
 
@@ -100,6 +147,19 @@ export const ru = {
100
147
  none: "рецепт не описан",
101
148
  },
102
149
 
150
+ // Зрелость записи каталога. Считается по доказательству; объявить её нельзя — см.
151
+ // entryLifecycle в tool/lib/manifest.mjs.
152
+ lifecycle: {
153
+ stable: "доказана шишкой из журнала",
154
+ experimental: "доказательство не из журнала — запись условная",
155
+ deprecated: "выведена из употребления",
156
+ unknownReplacement: (v) => `superseded_by: ${v} — такой записи в каталоге нет`,
157
+ noReplacement: "lifecycle: deprecated без superseded_by — не назван тот, кто заменяет",
158
+ notDeclarable: (v) => `lifecycle: ${v} объявлять нельзя — зрелость считается по доказательству`,
159
+ unknown: (v) => `lifecycle: ${v} — такого состояния нет; объявляется только deprecated`,
160
+ installDeprecated: (slug, by) =>
161
+ `запись ${slug} выведена из употребления, её заменяет ${by}`,
162
+ },
103
163
  manifest: {
104
164
  noGatesBlock: "в .aqk.yml нет блока gates:",
105
165
  alreadyDeclared: "уже объявлен",
@@ -300,6 +360,7 @@ export const ru = {
300
360
  },
301
361
 
302
362
  start: {
363
+ noRecipeHere: "нужен инструмент, которого нет на этой машине",
303
364
  initFailed: (cmd) => `Не получилось разложить комплект. Начни с ${cmd}`,
304
365
  tooManyFiles: (n) => `В репозитории уже ${n} файлов кода — это другой сценарий.`,
305
366
  useDoctor: (cmd) => `${cmd} осмотрит, что есть, и разделит записи на три списка:`,
@@ -11,17 +11,17 @@ const AGENTS_MD = `# AGENTS.md
11
11
 
12
12
  ## Hard rules
13
13
 
14
- - **A plan before code.** A non-trivial task starts with a plan a human approved in words.
15
- - **A red test before code.** First a check that fails, then the implementation.
16
- - **Three attempts maximum.** Not solved in three — stop and ask a human, not a fourth try.
17
- - **Secrets only in the environment.** Never in code, logs or commits.
18
- - **Only the files the task is about.** No fixing things "while we are here".
19
- - **Done = proven.** Name the arbiter: a test, a live run, a check against the source.
14
+ - **A plan before code.** A non-trivial task starts with a plan a human approved in words. <!-- aqk: human -->
15
+ - **A red test before code.** First a check that fails, then the implementation. <!-- aqk: human -->
16
+ - **Three attempts maximum.** Not solved in three — stop and ask a human, not a fourth try. <!-- aqk: human -->
17
+ - **Secrets only in the environment.** Never in code, logs or commits. <!-- aqk: secrets-not-in-code -->
18
+ - **Only the files the task is about.** No fixing things "while we are here". <!-- aqk: human -->
19
+ - **Done = proven.** Name the arbiter: a test, a live run, a check against the source. <!-- aqk: human -->
20
20
  "Looks like it works" is not done.
21
- - **Never swallow an error.** Either handled and logged, or re-raised.
22
- - **A fork in the road is a question for a human.** Departing from an agreed decision is not
21
+ - **Never swallow an error.** Either handled and logged, or re-raised. <!-- aqk: swallowed-error -->
22
+ - **A fork in the road is a question for a human.** Departing from an agreed decision is not <!-- aqk: human -->
23
23
  documented with a code comment.
24
- - **Report on your work with the kit with a command, not with words.** When you are done, run
24
+ - **Report on your work with the kit with a command, not with words.** When you are done, run <!-- aqk: human -->
25
25
  \`aqk report\`. It is assembled from an actual run: a summary from memory always picks the
26
26
  convenient parts and stays quiet about a gate standing on the weakest recipe.
27
27