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,65 @@
|
|
|
1
|
+
// tool/selfcheck/units-banner.mjs — заставка: первая и почти единственная встреча с человеком.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ПРОВЕРЯТЬ КАРТИНКУ. Не ради красоты. Заставка шириной больше окна разъезжается в кашу
|
|
4
|
+
// и портит именно то впечатление, ради которого её и добавили. А в терминале без UTF-8 графика
|
|
5
|
+
// Брайлем превращается в вопросительные знаки — и человек решает, что инструмент сломан.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-banner.mjs
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { banner, BANNER_WIDTH } from "../lib/banner.mjs";
|
|
11
|
+
|
|
12
|
+
test("заставка влезает в узкое окно", () => {
|
|
13
|
+
const w = Math.max(...banner().split("\n").map((l) => [...l].length));
|
|
14
|
+
assert.ok(w <= 40, `ширина ${w}, а бывают окна и в 40 колонок`);
|
|
15
|
+
assert.equal(w, BANNER_WIDTH, "объявленная ширина обязана совпадать с настоящей");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Версии в заставке НЕТ намеренно — решение владельца: выпуски частые, и номер в картинке
|
|
19
|
+
// устаревает быстрее всего остального. За версией есть `--version`, она печатается отдельно.
|
|
20
|
+
test("версии в заставке нет", () => {
|
|
21
|
+
assert.doesNotMatch(banner(), /\d+\.\d+\.\d+/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Терминал без UTF-8 превратит Брайль в мусор. Тогда честнее короткая строка, чем каша,
|
|
25
|
+
// по которой человек решит, что инструмент сломан.
|
|
26
|
+
test("без UTF-8 вместо графики короткая строка", () => {
|
|
27
|
+
const plain = banner({ LANG: "C" });
|
|
28
|
+
assert.doesNotMatch(plain, /[⣿█]/);
|
|
29
|
+
assert.match(plain, /aqk/i);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("отказ от графики уважается переменной", () => {
|
|
33
|
+
assert.doesNotMatch(banner({ LANG: "ru_RU.UTF-8", AQK_NO_ART: "1" }), /[⣿█]/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Кошка центрируется по ВИДИМОЙ части, а не по началу строки: пустой Брайль `⠀` занимает место
|
|
37
|
+
// и ничего не рисует. При равных отступах рисунок выглядел сдвинутым вправо на целый знак —
|
|
38
|
+
// поймано глазом владельца, а не прогоном, и потому закреплено здесь.
|
|
39
|
+
test("кошка стоит по центру надписи", () => {
|
|
40
|
+
const lines = banner().split("\n");
|
|
41
|
+
const visible = (l, blanks) => {
|
|
42
|
+
const a = [...l];
|
|
43
|
+
let lo = -1, hi = -1;
|
|
44
|
+
a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
|
|
45
|
+
return lo < 0 ? null : (lo + hi) / 2;
|
|
46
|
+
};
|
|
47
|
+
const catMid = Math.max(...lines.slice(0, 6).map((l) => visible(l, new Set([" ", "⠀"]))));
|
|
48
|
+
const artMid = Math.max(...lines.slice(7, 13).map((l) => visible(l, new Set([" "]))));
|
|
49
|
+
assert.ok(Math.abs(catMid - artMid) <= 1, `центры разошлись: кошка ${catMid}, буквы ${artMid}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Подпись центрируется по видимому центру букв, а не по краю строки. Отступ, подобранный на
|
|
53
|
+
// глаз, держится до первой правки рисунка — посчитанный переживёт её.
|
|
54
|
+
test("подпись стоит по центру надписи", () => {
|
|
55
|
+
const lines = banner().split("\n");
|
|
56
|
+
const mid = (l, blanks) => {
|
|
57
|
+
const a = [...l];
|
|
58
|
+
let lo = -1, hi = -1;
|
|
59
|
+
a.forEach((ch, i) => { if (!blanks.has(ch)) { if (lo < 0) lo = i; hi = i; } });
|
|
60
|
+
return lo < 0 ? null : (lo + hi) / 2;
|
|
61
|
+
};
|
|
62
|
+
const letters = Math.max(...lines.slice(7, 13).map((l) => mid(l, new Set([" "]))));
|
|
63
|
+
const tag = mid(lines[lines.length - 1], new Set([" "]));
|
|
64
|
+
assert.ok(Math.abs(letters - tag) <= 1, `подпись не по центру: буквы ${letters}, подпись ${tag}`);
|
|
65
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tool/selfcheck/units-brief.mjs — короткая строка присутствия и её ограничитель.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ЭТО ВООБЩЕ. Владелец: «скачал и че дальше, не понятно, работает он вообще или нет».
|
|
4
|
+
// Хук pre-commit молчит на успехе — это его умолчание, проверено по документации: вывод
|
|
5
|
+
// показывается только при провале. То есть комплект, который всё держит, для человека
|
|
6
|
+
// неотличим от невставленного. Это ровно наш собственный порок: тишина неотличима от успеха.
|
|
7
|
+
//
|
|
8
|
+
// node --test tool/selfcheck/units-brief.mjs
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { briefLine, adviceDue, pickAdvice, updateNotice, updateWanted } from "../lib/brief.mjs";
|
|
12
|
+
import { CATALOGS } from "../i18n/index.mjs";
|
|
13
|
+
|
|
14
|
+
const T = CATALOGS.ru;
|
|
15
|
+
|
|
16
|
+
// Строка присутствия печатается ВСЕГДА, в том числе когда всё хорошо: именно тогда она и нужна.
|
|
17
|
+
test("строка присутствия несёт числа и уровень", () => {
|
|
18
|
+
const s = briefLine({ held: 12, todo: 3, level: 2, red: [] }, T);
|
|
19
|
+
assert.match(s, /12/);
|
|
20
|
+
assert.match(s, /3/);
|
|
21
|
+
assert.match(s, /AQK-2/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Красное называется первым и поимённо: человек должен видеть, что чинить, не листая вывод.
|
|
25
|
+
test("упавшие гейты названы в самой строке", () => {
|
|
26
|
+
const s = briefLine({ held: 12, todo: 0, level: 1, red: ["file-size-limit", "duplicate-code"] }, T);
|
|
27
|
+
assert.match(s, /file-size-limit/);
|
|
28
|
+
assert.match(s, /duplicate-code/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Уровень может быть не вычислен — и тогда так и говорим, а не подставляем ноль.
|
|
32
|
+
test("невычисленный уровень не выдумывается", () => {
|
|
33
|
+
const s = briefLine({ held: 0, todo: 5, level: -1, red: [] }, T);
|
|
34
|
+
assert.doesNotMatch(s, /AQK--1|AQK-0/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// --- ограничитель совета -----------------------------------------------------
|
|
38
|
+
// Совет на КАЖДОМ коммите превращается в шум, а шум пролистывают вместе с настоящими
|
|
39
|
+
// находками. Раз в сутки — это заметно и не мешает.
|
|
40
|
+
test("совет не повторяется чаще раза в сутки", () => {
|
|
41
|
+
const now = Date.parse("2026-09-08T20:00:00Z");
|
|
42
|
+
assert.equal(adviceDue(null, now), true, "первый раз показывается");
|
|
43
|
+
assert.equal(adviceDue("2026-09-08T19:00:00Z", now), false, "час назад — рано");
|
|
44
|
+
assert.equal(adviceDue("2026-09-07T19:00:00Z", now), true, "сутки прошли");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("испорченная отметка времени не мешает показать совет", () => {
|
|
48
|
+
assert.equal(adviceDue("не дата", Date.parse("2026-09-08T20:00:00Z")), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- выбор совета ------------------------------------------------------------
|
|
52
|
+
// Один совет за раз, а не список: список читается как «у вас всё плохо» и не помогает выбрать.
|
|
53
|
+
test("советуется одна запись, самая первая из непоставленных", () => {
|
|
54
|
+
const a = pickAdvice([{ slug: "secrets-not-in-code" }, { slug: "file-size-limit" }]);
|
|
55
|
+
assert.equal(a.slug, "secrets-not-in-code");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("советовать нечего — совета нет, а не пустая строка", () => {
|
|
59
|
+
assert.equal(pickAdvice([]), null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- уведомление об обновлении -----------------------------------------------
|
|
63
|
+
// Владелец: «выпускаем часто, никто не будет обновлять — скачают старую версию, а мы там уже
|
|
64
|
+
// ошибки исправили». Верно: у pre-commit версия закреплена в rev:, у GitHub Action в теге,
|
|
65
|
+
// и сами они не двигаются. У `npx` без версии проблемы нет — он всегда берёт свежее.
|
|
66
|
+
//
|
|
67
|
+
// АВТООБНОВЛЕНИЯ НЕТ НАМЕРЕННО. В этот же день выпущена запись, краснеющая на `@latest`:
|
|
68
|
+
// «версия не закреплена, завтра приедет другая». Инструмент, который сам себя подменяет, стоя
|
|
69
|
+
// на воротах коммита, делал бы ровно то, что мы запрещаем другим.
|
|
70
|
+
test("новая версия называется, старая не тревожит", () => {
|
|
71
|
+
assert.ok(updateNotice("0.8.0", "0.9.0", {}, T));
|
|
72
|
+
assert.equal(updateNotice("0.9.0", "0.9.0", {}, T), null);
|
|
73
|
+
assert.equal(updateNotice("0.9.0", "0.8.0", {}, T), null, "откат назад — не обновление");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Сравнение по числам, а не по строкам: «0.10.0» строкой меньше «0.9.0», и уведомление
|
|
77
|
+
// пропало бы ровно на десятом выпуске — тихо и надолго.
|
|
78
|
+
test("версии сравниваются числами, а не как текст", () => {
|
|
79
|
+
assert.ok(updateNotice("0.9.0", "0.10.0", {}, T));
|
|
80
|
+
assert.equal(updateNotice("0.10.0", "0.9.0", {}, T), null);
|
|
81
|
+
assert.ok(updateNotice("1.2.9", "1.10.0", {}, T));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Способ обновления зависит от того, как поставлено: у pre-commit это autoupdate, а не npm.
|
|
85
|
+
// Совет «сделай npm i -g» человеку, у которого хук, — это совет мимо, и он его не выполнит.
|
|
86
|
+
test("совет об обновлении соответствует способу установки", () => {
|
|
87
|
+
assert.match(updateNotice("0.8.0", "0.9.0", { PRE_COMMIT: "1" }, T), /autoupdate/);
|
|
88
|
+
assert.doesNotMatch(updateNotice("0.8.0", "0.9.0", {}, T), /autoupdate/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// В конвейере уведомление — шум и лишний запрос: там версия закреплена сознательно, и человека,
|
|
92
|
+
// который бы его прочитал, у экрана нет.
|
|
93
|
+
test("в конвейере и при отказе проверять не спрашиваем вовсе", () => {
|
|
94
|
+
assert.equal(updateWanted({ CI: "true" }), false);
|
|
95
|
+
assert.equal(updateWanted({ AQK_UPDATE: "0" }), false);
|
|
96
|
+
assert.equal(updateWanted({}), true);
|
|
97
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// tool/selfcheck/units-context.mjs — блок состояния, который уходит В КОНТЕКСТ агента.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ. У этого текста единственный читатель — машина, и цена ошибки другая,
|
|
4
|
+
// чем у терминального вывода: человек, увидев пустую строку, переспросит, а агент примет её за
|
|
5
|
+
// утверждение. Поэтому главная проверка здесь одна и та же во всех видах: **тишина не означает
|
|
6
|
+
// «чисто»**. Блок обязан говорить «неизвестно» там, где не знает, — иначе он врёт ровно тем
|
|
7
|
+
// способом, против которого написан весь комплект.
|
|
8
|
+
//
|
|
9
|
+
// node --test tool/selfcheck/units-context.mjs
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { contextBlock, countArbiters, parseLastRun, withHook, hasOurHook, portableSelf } from "../commands/context.mjs";
|
|
13
|
+
import { CATALOGS } from "../i18n/index.mjs";
|
|
14
|
+
import { commandRows } from "../lib/core.mjs";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
|
|
17
|
+
const T = CATALOGS.ru.context;
|
|
18
|
+
const base = {
|
|
19
|
+
entry: "AGENTS.md",
|
|
20
|
+
level: { reached: 1, top: 3, missing: "гейты не доказаны" },
|
|
21
|
+
rules: { total: 14, machine: 2, human: 12 },
|
|
22
|
+
run: { when: "2026-09-08 11:00", red: [], skipped: 0, stale: false },
|
|
23
|
+
ratchets: [],
|
|
24
|
+
};
|
|
25
|
+
const text = (over = {}) => contextBlock({ ...base, ...over }, T).join("\n");
|
|
26
|
+
|
|
27
|
+
// ГЛАВНАЯ. Прогона не было — сказать «неизвестно» словом. Пустая строка на этом месте
|
|
28
|
+
// прочитается агентом как «красных нет», и он пойдёт писать код по несуществующему разрешению.
|
|
29
|
+
test("без прогона блок говорит «неизвестно», а не молчит", () => {
|
|
30
|
+
const t = text({ run: null });
|
|
31
|
+
assert.match(t, /НЕИЗВЕСТНО/);
|
|
32
|
+
assert.doesNotMatch(t, /красных нет/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("красные гейты названы поимённо", () => {
|
|
36
|
+
const t = text({ run: { ...base.run, red: ["file-size-limit", "duplicate-code"] } });
|
|
37
|
+
assert.match(t, /file-size-limit/);
|
|
38
|
+
assert.match(t, /duplicate-code/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Проект с двадцатью красными не должен вытеснять собой весь контекст: ровно та деградация,
|
|
42
|
+
// ради избежания которой блок и делается коротким.
|
|
43
|
+
test("длинный список красных обрезается и называет остаток числом", () => {
|
|
44
|
+
const red = Array.from({ length: 20 }, (_, i) => `гейт-${i}`);
|
|
45
|
+
const t = text({ run: { ...base.run, red } });
|
|
46
|
+
assert.match(t, /ещё 15/);
|
|
47
|
+
assert.ok(!t.includes("гейт-9"), "шестой и дальше в список не попадают");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Прогон, сделанный до последнего коммита, описывает не тот код, что лежит перед агентом.
|
|
51
|
+
test("устаревший прогон помечен, а не выдан за свежий", () => {
|
|
52
|
+
const t = text({ run: { ...base.run, stale: true } });
|
|
53
|
+
assert.match(t, /СТАРЕЕ/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// То, ради чего второй пользователь и оценил promise-has-gate: «держит человек» значит
|
|
57
|
+
// «не держит никто», и это обязано быть сказано словами, а не выведено читателем из цифр.
|
|
58
|
+
test("правила без машинного арбитра названы прямо", () => {
|
|
59
|
+
const t = text({ rules: { total: 12, machine: 0, human: 12 } });
|
|
60
|
+
assert.match(t, /не держит никто/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("нет манифеста — уровень не выдумывается", () => {
|
|
64
|
+
const t = text({ level: null });
|
|
65
|
+
assert.match(t, /не вычислен/);
|
|
66
|
+
assert.doesNotMatch(t, /AQK-0 из/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Потолок: блок влезает в глаза целиком. Замер 2026-09-08 — вход целиком в контекст роняет
|
|
70
|
+
// точность у всех проверенных моделей по мере роста, поэтому предел здесь предмет проверки,
|
|
71
|
+
// а не пожелание.
|
|
72
|
+
test("блок не разрастается даже на худшем входе", () => {
|
|
73
|
+
const lines = contextBlock({
|
|
74
|
+
...base,
|
|
75
|
+
run: { when: "…", red: Array.from({ length: 40 }, (_, i) => `г-${i}`), skipped: 9, stale: true },
|
|
76
|
+
ratchets: Array.from({ length: 12 }, (_, i) => ({ name: `р-${i}`, count: i })),
|
|
77
|
+
}, T);
|
|
78
|
+
assert.ok(lines.length <= 16, `строк ${lines.length}, предел 16`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// --- разбор источников -------------------------------------------------------
|
|
82
|
+
// Имя арбитра — это имя гейта, а в именах гейтов есть дефисы. Класс `[^\s>-]` обрывал их
|
|
83
|
+
// молча: первый же живой запуск показал 13 правил вместо 14 и одного машинного вместо двух.
|
|
84
|
+
// Молча — потому что число выглядит правдоподобным, пока его не с чем сверить.
|
|
85
|
+
test("имя арбитра с дефисом считается машинным, а не теряется", () => {
|
|
86
|
+
const md = [
|
|
87
|
+
"- Правило один. <!-- aqk: deps-are-pinned -->",
|
|
88
|
+
"- Правило два. <!-- aqk: человек -->",
|
|
89
|
+
"- Правило три. <!-- aqk: gates -->",
|
|
90
|
+
].join("\n");
|
|
91
|
+
assert.deepEqual(countArbiters(md, ["человек"]), { total: 3, machine: 2, human: 1 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("отчёт прошлого прогона отдаёт красные и число непроверенных", () => {
|
|
95
|
+
const r = parseLastRun([
|
|
96
|
+
"# aqk doctor --run — 2026-09-08 11:00",
|
|
97
|
+
"level: AQK-1",
|
|
98
|
+
"✔ smoke — 1.0s",
|
|
99
|
+
"✘ file-size-limit — 0.1s",
|
|
100
|
+
"~ dead-code — нет инструмента",
|
|
101
|
+
].join("\n"));
|
|
102
|
+
assert.equal(r.when, "2026-09-08 11:00");
|
|
103
|
+
assert.deepEqual(r.red, ["file-size-limit"]);
|
|
104
|
+
assert.equal(r.skipped, 1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("пустой отчёт — это не «чисто», а отсутствие данных", () => {
|
|
108
|
+
assert.equal(parseLastRun(""), null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Найдено ЗАМЕРОМ на шести чужих проектах, до того как хук попал в init: на `flask`, где нет
|
|
112
|
+
// ни .aqk.yml, ни AGENTS.md, блок всё равно писал «Свод правил: AGENTS.md». Тот же класс, что
|
|
113
|
+
// у doctor неделей раньше: умолчание выдаётся за факт, потому что на нашем репозитории
|
|
114
|
+
// умолчание и факт совпадают. Назвать агенту несуществующий файл хуже, чем промолчать: он
|
|
115
|
+
// пойдёт его читать и получит пустоту вместо правил.
|
|
116
|
+
test("несуществующая точка входа не называется как свод правил", () => {
|
|
117
|
+
const t = text({ level: null, run: null, rules: null, entryExists: false });
|
|
118
|
+
assert.doesNotMatch(t, /AGENTS\.md/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("существующая точка входа называется", () => {
|
|
122
|
+
const t = text({ entryExists: true });
|
|
123
|
+
assert.match(t, /AGENTS\.md/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- хук уходит в ОБЩИЙ файл настроек ----------------------------------------
|
|
127
|
+
// `.claude/settings.json` кладут в git: он общий на команду, в отличие от settings.local.json.
|
|
128
|
+
// Значит команда внутри него обязана работать не только на той машине, где её записали.
|
|
129
|
+
// На машине разработчика SELF — абсолютный путь; у соседа такого пути нет, и хук молча
|
|
130
|
+
// не сработает. Молча — то есть блок состояния просто не появится, и никто не узнает.
|
|
131
|
+
test("абсолютный путь заменяется переносимым вызовом", () => {
|
|
132
|
+
assert.equal(portableSelf("node /home/x/aqk/tool/program.mjs"), "npx agent-quality-kit");
|
|
133
|
+
assert.equal(portableSelf("node C:\\x\\aqk\\tool\\program.mjs"), "npx agent-quality-kit");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("переносимые вызовы остаются как есть", () => {
|
|
137
|
+
assert.equal(portableSelf("aqk"), "aqk");
|
|
138
|
+
assert.equal(portableSelf("npx agent-quality-kit"), "npx agent-quality-kit");
|
|
139
|
+
assert.equal(portableSelf("node tool/program.mjs"), "node tool/program.mjs");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Второй такой же хук — блок в контексте дважды: вдвое больше токенов и ровно ноль пользы.
|
|
143
|
+
test("хук не задваивается", () => {
|
|
144
|
+
const once = withHook({}, "aqk context");
|
|
145
|
+
assert.ok(hasOurHook(once, "aqk context"));
|
|
146
|
+
assert.equal(once.hooks.SessionStart.length, 1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Чужие настройки в том же файле — права доступа, другие хуки — обязаны пережить установку.
|
|
150
|
+
test("чужие настройки переживают установку хука", () => {
|
|
151
|
+
const before = { permissions: { deny: ["Read(./.env)"] }, hooks: { Stop: [{ hooks: [] }] } };
|
|
152
|
+
const after = withHook(before, "aqk context");
|
|
153
|
+
assert.deepEqual(after.permissions, before.permissions);
|
|
154
|
+
assert.equal(after.hooks.Stop.length, 1);
|
|
155
|
+
assert.equal(after.hooks.SessionStart.length, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- карта команд ------------------------------------------------------------
|
|
159
|
+
// Владелец: «агент плохо читает инструкцию — нужно влить карту и сам свод». Карта имеет смысл
|
|
160
|
+
// ровно до тех пор, пока она не отстала от программы. Второй список, живущий рядом с первым,
|
|
161
|
+
// через месяц врёт — это записано у нас в README про храповики и верно здесь буквально так же.
|
|
162
|
+
// Поэтому карта и справка собираются ИЗ ОДНОГО списка, а эта проверка сторожит, что список
|
|
163
|
+
// не отстал от диспетчера: команда, добавленная в switch и забытая в списке, роняет её.
|
|
164
|
+
test("карта команд не отстаёт от диспетчера", async () => {
|
|
165
|
+
const src = await readFile(new URL("../program.mjs", import.meta.url), "utf8");
|
|
166
|
+
// Флаговые формы (`--version`, `-v`) в карту не входят: карта перечисляет КОМАНДЫ, а флаг —
|
|
167
|
+
// второе имя той же команды. Требовать их здесь значило бы дублировать строку справки.
|
|
168
|
+
const dispatched = [...src.matchAll(/^\s{4}case "([a-z][a-z-]*)":/gm)].map((m) => m[1]);
|
|
169
|
+
assert.ok(dispatched.length >= 10, `в диспетчере найдено ${dispatched.length} команд — разбор сломался`);
|
|
170
|
+
const listed = new Set(commandRows(CATALOGS.ru).map((r) => r.name));
|
|
171
|
+
const missing = dispatched.filter((n) => !listed.has(n));
|
|
172
|
+
assert.deepEqual(missing, [], `в карте нет: ${missing.join(", ")}`);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Полный блок — то, за что владелец согласился платить токенами: свод правил дословно, а не
|
|
176
|
+
// ссылка на него. Если он не дословный, плата внесена, а товар не получен.
|
|
177
|
+
test("полный блок несёт свод правил дословно", () => {
|
|
178
|
+
const rules = "- Правило одно. <!-- aqk: человек -->\n- Правило два.";
|
|
179
|
+
const t = contextBlock({ ...base, entryExists: true, full: { entry: "AGENTS.md", rows: [{ cmd: "aqk context", text: "состояние" }], text: rules } }, T).join("\n");
|
|
180
|
+
assert.ok(t.includes(rules), "текст свода обязан войти целиком");
|
|
181
|
+
assert.match(t, /aqk context/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("без --full свод не вливается — только ссылка на него", () => {
|
|
185
|
+
const t = text({ entryExists: true });
|
|
186
|
+
assert.doesNotMatch(t, /Правило одно/);
|
|
187
|
+
assert.match(t, /AGENTS\.md/);
|
|
188
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// tool/selfcheck/units-evidence.mjs — проверки привязки доказательства к дифу.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНЫМ ФАЙЛОМ. `units.mjs` во второй раз перерос собственный предел в 500 строк —
|
|
4
|
+
// его поймал наш же гейт file-size-limit, и это уже второй такой развод (первым был
|
|
5
|
+
// `units-level.mjs`). Шов по смыслу: здесь всё про то, чем доказан диф, и ничего больше.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-evidence.mjs
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
// --- покрытие дифа доказательством ------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// ЗАЧЕМ. Правило «готово = доказано» было единственным центральным правилом свода, за которым
|
|
15
|
+
// не следила машина: сторожем стоял человек. Прогон donecheck по восьми нашим коммитам показал,
|
|
16
|
+
// чего это стоило: `.github/workflows/publish.yml` менялся и не был назван ни одной командой
|
|
17
|
+
// проверки — и именно он оказался сломан. Механизм взят оттуда (AtharvaMaik/donecheck, MIT),
|
|
18
|
+
// сопоставление сделано СТРОГИМ, в отличие от него: он считает файл покрытым и по голому имени,
|
|
19
|
+
// а имя `check.sh` в нашем каталоге носят двадцать разных файлов.
|
|
20
|
+
test("покрытие: файл считается доказанным, только если гейт назвал ЕГО путь", async () => {
|
|
21
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
22
|
+
const res = [
|
|
23
|
+
{ name: "tests", cmd: "node --test", out: "ok 1 - src/pay.js:12 покрыт" },
|
|
24
|
+
{ name: "lint", cmd: "bash kit/gates/x/check.sh .", out: "" },
|
|
25
|
+
];
|
|
26
|
+
const cov = coverage(["src/pay.js", "src/refund.js"], res);
|
|
27
|
+
assert.deepEqual(cov.covered.get("src/pay.js"), ["tests"]);
|
|
28
|
+
// refund.js не назван никем, но `lint` был направлен в корень — значит он в «молчании»,
|
|
29
|
+
// а не в «никто не смотрел». Разница между этими двумя и есть смысл третьего состояния.
|
|
30
|
+
assert.deepEqual(cov.silent.get("src/refund.js"), ["lint"]);
|
|
31
|
+
assert.deepEqual(cov.uncovered, []);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("покрытие: голое имя файла не считается доказательством", async () => {
|
|
35
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
36
|
+
// «check.sh» в выводе не означает, что проверен ИМЕННО kit/gates/todo/check.sh: этим именем
|
|
37
|
+
// в каталоге зовутся два десятка разных файлов. Мягкое сравнение дало бы тишину — то есть
|
|
38
|
+
// ровно тот отказ, против которого написана вся эта проверка.
|
|
39
|
+
const res = [{ name: "g", cmd: "bash run.sh", out: "проверено check.sh" }];
|
|
40
|
+
const cov = coverage(["kit/gates/todo/check.sh"], res);
|
|
41
|
+
assert.deepEqual(cov.uncovered, ["kit/gates/todo/check.sh"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("покрытие: путь с ./ и цветом в выводе всё равно засчитывается", async () => {
|
|
45
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
46
|
+
const esc = String.fromCharCode(27);
|
|
47
|
+
const res = [{ name: "g", cmd: "x", out: `${esc}[31m./src/pay.js:9${esc}[0m нашлось` }];
|
|
48
|
+
const cov = coverage(["src/pay.js"], res);
|
|
49
|
+
assert.deepEqual(cov.uncovered, []);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Расписка обязана меняться, когда меняется хоть что-то из того, о чём она отчитывается:
|
|
53
|
+
// базовый коммит, набор команд, содержимое файлов. Иначе «прогнал, потом поправил ещё три
|
|
54
|
+
// файла» неотличимо от «прогнал».
|
|
55
|
+
test("расписка: хеш доказательства меняется от правки файла и от смены команды", async () => {
|
|
56
|
+
const { evidenceHash } = await import("../lib/evidence.mjs");
|
|
57
|
+
const a = evidenceHash("base1", [{ cmd: "t" }], [["f.js", "one"]]);
|
|
58
|
+
assert.equal(evidenceHash("base1", [{ cmd: "t" }], [["f.js", "one"]]), a);
|
|
59
|
+
assert.notEqual(evidenceHash("base1", [{ cmd: "t" }], [["f.js", "two"]]), a);
|
|
60
|
+
assert.notEqual(evidenceHash("base1", [{ cmd: "u" }], [["f.js", "one"]]), a);
|
|
61
|
+
assert.notEqual(evidenceHash("base2", [{ cmd: "t" }], [["f.js", "one"]]), a);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("покрытие: молчащая проверка отличается и от назвавшей, и от не смотревшей", async () => {
|
|
65
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
66
|
+
const res = [
|
|
67
|
+
{ name: "size", cmd: "bash check.sh .", out: "" }, // направлен в корень, промолчал
|
|
68
|
+
{ name: "tests", cmd: "node --test", out: "src/pay.js:1 ок" }, // назвал файл
|
|
69
|
+
];
|
|
70
|
+
const dirs = new Set(["."]);
|
|
71
|
+
const cov = coverage(["src/pay.js", "src/quiet.js"], res, (p) => dirs.has(p));
|
|
72
|
+
assert.deepEqual(cov.covered.get("src/pay.js"), ["tests"]);
|
|
73
|
+
assert.deepEqual(cov.silent.get("src/quiet.js"), ["size"]);
|
|
74
|
+
assert.deepEqual(cov.uncovered, []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("покрытие: файл вне всех целей остаётся не тронутым никем", async () => {
|
|
78
|
+
const { coverage } = await import("../lib/evidence.mjs");
|
|
79
|
+
const res = [{ name: "size", cmd: "bash check.sh tool", out: "" }];
|
|
80
|
+
const cov = coverage(["tool/a.js", ".github/workflows/ci.yml"], res, (p) => p === "tool");
|
|
81
|
+
assert.deepEqual(cov.silent.get("tool/a.js"), ["size"]);
|
|
82
|
+
assert.deepEqual(cov.uncovered, [".github/workflows/ci.yml"]);
|
|
83
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// tool/selfcheck/units-learn.mjs — проверки разбора локальных логов сессий.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ ОТДЕЛЬНО. Файл модульных проверок разводится по смыслу третий раз (после units-level и
|
|
4
|
+
// units-evidence) — предел в 500 строк держит наш же гейт file-size-limit. Здесь всё про то,
|
|
5
|
+
// как из переписки достаются кандидаты в правила, и ничего больше.
|
|
6
|
+
//
|
|
7
|
+
// node --test tool/selfcheck/units-learn.mjs
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
|
|
12
|
+
// Каталог логов зовётся по рабочему пути, где всё, кроме букв и цифр, заменено на дефис.
|
|
13
|
+
// Проверено на живой машине: /home/ser/projects/aqk → -home-ser-projects-aqk,
|
|
14
|
+
// /home/ser/projects/audit_project → -home-ser-projects-audit-project.
|
|
15
|
+
test("logSlug: путь проекта превращается в имя каталога логов", async () => {
|
|
16
|
+
const { logSlug } = await import("../commands/learn.mjs");
|
|
17
|
+
assert.equal(logSlug("/home/ser/projects/aqk"), "-home-ser-projects-aqk");
|
|
18
|
+
assert.equal(logSlug("/home/ser/projects/audit_project"), "-home-ser-projects-audit-project");
|
|
19
|
+
assert.equal(logSlug("C:\\work\\my.app"), "c-work-my-app");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Отбор наставлений. Меряно на 1619 уникальных напечатанных репликах: маркеры дают 79 штук,
|
|
23
|
+
// то есть 4%. Точность неполная и названа вслух — это список кандидатов, а не находок.
|
|
24
|
+
test("looksLikeRule: наставление отличается от обычной реплики", async () => {
|
|
25
|
+
const { looksLikeRule } = await import("../commands/learn.mjs");
|
|
26
|
+
assert.equal(looksLikeRule("файл не трогай AI_main_inst.md"), true);
|
|
27
|
+
assert.equal(looksLikeRule("делай прогон с базой обязательно"), true);
|
|
28
|
+
assert.equal(looksLikeRule("never commit secrets"), true);
|
|
29
|
+
assert.equal(looksLikeRule("ок го дальше"), false);
|
|
30
|
+
assert.equal(looksLikeRule("а что там по отчёту"), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Длинная вставка наставлением не считается: в логе лежат и вставленный вывод команд, и куски
|
|
34
|
+
// файлов. Первый прогон без этого отсева выдал «agent quality kit» 44 раза — то есть пути и
|
|
35
|
+
// ссылки, а не правила.
|
|
36
|
+
test("looksLikeRule: длинная вставка и код не считаются правилом", async () => {
|
|
37
|
+
const { looksLikeRule } = await import("../commands/learn.mjs");
|
|
38
|
+
assert.equal(looksLikeRule("никогда " + "x".repeat(500)), false);
|
|
39
|
+
assert.equal(looksLikeRule("нельзя\n```\ncode\n```"), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Главное утверждение команды: сказано вслух и НЕ записано. Слово из наставления, которого нет
|
|
43
|
+
// в точке входа, — повод завести правило; совпавшее — повод не шуметь.
|
|
44
|
+
test("saidNotWritten: правило, уже стоящее в точке входа, не показывается", async () => {
|
|
45
|
+
const { saidNotWritten } = await import("../commands/learn.mjs");
|
|
46
|
+
const entry = "# правила\n- Секреты никогда не попадают в код.\n";
|
|
47
|
+
assert.equal(saidNotWritten("никогда не коммить секреты в код", entry), false);
|
|
48
|
+
assert.equal(saidNotWritten("делай прогон с базой обязательно", entry), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Русский язык склоняет. «локальный костыль» в реплике и «до местного костыля» в своде — одно и
|
|
52
|
+
// то же правило, и по целому слову они не совпадают. Найдено живым прогоном: без сверки по
|
|
53
|
+
// основе первым же кандидатом вышло правило, внесённое в точку входа в тот же день.
|
|
54
|
+
test("saidNotWritten: склонение не делает записанное правило незаписанным", async () => {
|
|
55
|
+
const { saidNotWritten } = await import("../commands/learn.mjs");
|
|
56
|
+
const entry = "- Любое сомнение проверяется снаружи, чтобы не выдумать местного костыля.";
|
|
57
|
+
assert.equal(saidNotWritten("любое сомнение проверяем всегда чтобы не сделать костыль", entry), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Отбор по promptSource — то, ради чего команда вообще работает. В логе 25 353 записи `user`,
|
|
61
|
+
// из них человеком напечатано 1912; остальное результаты инструментов, служебные вставки и
|
|
62
|
+
// принятые подсказки. Первая версия отбирала по длине и языку и выдавала вставленные пути.
|
|
63
|
+
test("typedFrom: берётся только напечатанное человеком", async () => {
|
|
64
|
+
const { typedFrom } = await import("../commands/learn.mjs");
|
|
65
|
+
const lines = [
|
|
66
|
+
JSON.stringify({ type: "user", promptSource: "typed", timestamp: "2026-09-08T10:00:00Z", message: { role: "user", content: "никогда так не делай" } }),
|
|
67
|
+
JSON.stringify({ type: "user", promptSource: "system", message: { role: "user", content: "служебное" } }),
|
|
68
|
+
JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "tool_result", content: "вывод" }] } }),
|
|
69
|
+
JSON.stringify({ type: "assistant", message: { role: "assistant", content: "ответ" } }),
|
|
70
|
+
"не json вовсе",
|
|
71
|
+
"",
|
|
72
|
+
].join("\n");
|
|
73
|
+
const got = typedFrom(lines);
|
|
74
|
+
assert.equal(got.length, 1);
|
|
75
|
+
assert.equal(got[0].text, "никогда так не делай");
|
|
76
|
+
assert.equal(got[0].when, "2026-09-08");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Текст блоками, а не строкой: так приходит реплика с приложенным файлом. Берём текстовые
|
|
80
|
+
// блоки и только их — картинка и результат инструмента правилом быть не могут.
|
|
81
|
+
test("typedFrom: реплика блоками собирается из текстовых блоков", async () => {
|
|
82
|
+
const { typedFrom } = await import("../commands/learn.mjs");
|
|
83
|
+
const line = JSON.stringify({
|
|
84
|
+
type: "user", promptSource: "typed", timestamp: "2026-09-08T11:00:00Z",
|
|
85
|
+
message: { role: "user", content: [{ type: "image" }, { type: "text", text: "всегда так" }] },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(typedFrom(line)[0].text, "всегда так");
|
|
88
|
+
});
|