agent-quality-kit 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -2
- package/README.ru.md +45 -2
- 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 +6 -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/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 +22 -1
- package/package.json +4 -1
- package/tool/commands/context.mjs +260 -0
- package/tool/commands/doctor.mjs +25 -18
- package/tool/commands/learn.mjs +159 -0
- package/tool/commands/project.mjs +1 -0
- package/tool/commands/report.mjs +33 -1
- package/tool/i18n/en-docs.mjs +85 -1
- package/tool/i18n/en.mjs +21 -36
- package/tool/i18n/ru-docs.mjs +87 -1
- package/tool/i18n/ru.mjs +21 -36
- package/tool/lib/core.mjs +30 -1
- package/tool/lib/evidence.mjs +124 -0
- package/tool/lib/manifest.mjs +29 -2
- package/tool/lib/prove.mjs +13 -1
- package/tool/lib/scope.mjs +10 -1
- package/tool/lib/templates.mjs +1 -0
- package/tool/program.mjs +19 -23
- package/tool/selfcheck/smoke.sh +242 -2
- package/tool/selfcheck/units-context.mjs +186 -0
- package/tool/selfcheck/units-evidence.mjs +83 -0
- package/tool/selfcheck/units-learn.mjs +88 -0
- package/tool/selfcheck/units-level.mjs +65 -3
- package/tool/selfcheck/units.mjs +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-quality-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
@@ -45,6 +45,9 @@
|
|
|
45
45
|
"entry": [
|
|
46
46
|
"tool/selfcheck/units.mjs",
|
|
47
47
|
"tool/selfcheck/units-level.mjs",
|
|
48
|
+
"tool/selfcheck/units-evidence.mjs",
|
|
49
|
+
"tool/selfcheck/units-learn.mjs",
|
|
50
|
+
"tool/selfcheck/units-context.mjs",
|
|
48
51
|
"tool/selfcheck/lifecycle.mjs"
|
|
49
52
|
],
|
|
50
53
|
"project": [
|
|
@@ -0,0 +1,260 @@
|
|
|
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 НАМЕРЕННО. Справочник на сайте перечисляет у SessionStart значения matcher
|
|
134
|
+
// (startup, resume, clear, compact), а таблица событий, ВШИТАЯ в установленную версию 2.1.263,
|
|
135
|
+
// показывает в колонке matcher прочерк. Одно из двух неверно, и выяснить это гаданием нельзя.
|
|
136
|
+
// Хук без matcher верен при любом из двух чтений: где matcher поддержан — сработает на всех
|
|
137
|
+
// источниках, где не поддержан — на всех тоже. Проверено чтением бинаря, не памятью.
|
|
138
|
+
const HOOK_FILE = [".claude", "settings.json"];
|
|
139
|
+
|
|
140
|
+
// Команда, которая пойдёт В ОБЩИЙ файл настроек, а значит и в чужие руки через git. `SELF`
|
|
141
|
+
// печатается для человека здесь и сейчас и на машине разработчика равен АБСОЛЮТНОМУ пути —
|
|
142
|
+
// у соседа по команде такого пути нет, и хук у него молча не сработает. Абсолютный путь
|
|
143
|
+
// заменяется на переносимый вызов из реестра; `aqk` и `npx …` переносимы сами и остаются.
|
|
144
|
+
function portableSelf(self = SELF) {
|
|
145
|
+
return /^node\s+[/\\]|^node\s+[A-Za-z]:/.test(self) ? "npx agent-quality-kit" : self;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hookEntry(cmd) {
|
|
149
|
+
return { hooks: [{ type: "command", command: cmd }] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Уже стоит? Тогда ничего не трогаем. Второй такой же хук значит блок в контексте дважды —
|
|
153
|
+
// вдвое больше токенов и ровно ноль пользы.
|
|
154
|
+
function hasOurHook(settings, cmd) {
|
|
155
|
+
const list = settings?.hooks?.SessionStart;
|
|
156
|
+
if (!Array.isArray(list)) return false;
|
|
157
|
+
return list.some((g) => (g?.hooks || []).some((h) => String(h?.command || "").includes(cmd)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function withHook(settings, cmd) {
|
|
161
|
+
const next = { ...(settings || {}) };
|
|
162
|
+
const hooks = { ...(next.hooks || {}) };
|
|
163
|
+
hooks.SessionStart = [...(Array.isArray(hooks.SessionStart) ? hooks.SessionStart : []), hookEntry(cmd)];
|
|
164
|
+
next.hooks = hooks;
|
|
165
|
+
return next;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function installHook(full = false) {
|
|
169
|
+
const T = L.context;
|
|
170
|
+
const path = join(CWD, ...HOOK_FILE);
|
|
171
|
+
const cmd = `${portableSelf()} context${full ? " --full" : ""}`;
|
|
172
|
+
|
|
173
|
+
let settings = {};
|
|
174
|
+
let existed = false;
|
|
175
|
+
if (await exists(path)) {
|
|
176
|
+
existed = true;
|
|
177
|
+
try {
|
|
178
|
+
settings = JSON.parse(await readFile(path, "utf8"));
|
|
179
|
+
} catch {
|
|
180
|
+
// Чужой файл с испорченным JSON перезаписывать нельзя: там могут быть чьи-то права
|
|
181
|
+
// доступа, и молча стереть их дороже, чем не поставить хук.
|
|
182
|
+
console.log(c.red(` ${T.hookBadJson(path)}`));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (hasOurHook(settings, cmd)) {
|
|
188
|
+
console.log(c.dim(` ${T.hookAlready(path)}`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await mkdir(join(CWD, HOOK_FILE[0]), { recursive: true });
|
|
193
|
+
await writeFile(path, JSON.stringify(withHook(settings, cmd), null, 2) + "\n", "utf8");
|
|
194
|
+
console.log(c.green(` ${existed ? T.hookAdded(path) : T.hookCreated(path)}`));
|
|
195
|
+
console.log(c.dim(` ${JSON.stringify({ SessionStart: [hookEntry(cmd)] })}`));
|
|
196
|
+
console.log(c.dim(` ${T.hookWhat}`));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function cmdContext(args = []) {
|
|
200
|
+
const full = args.includes("--full");
|
|
201
|
+
if (args.includes("--install")) return installHook(full);
|
|
202
|
+
|
|
203
|
+
const man = await readManifest();
|
|
204
|
+
const entry = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim()
|
|
205
|
+
|| "AGENTS.md";
|
|
206
|
+
|
|
207
|
+
let level = null;
|
|
208
|
+
if (man?.aqk) {
|
|
209
|
+
const { reached, steps } = await assessLevel(man, null);
|
|
210
|
+
const next = steps.find((s) => !s.ok);
|
|
211
|
+
// `assessLevel` без прогона помечает вторую ступень `needsProof`: файлы на месте, а гейты
|
|
212
|
+
// не доказаны. Сказать здесь «заведи samples и ratchets» значит послать чинить сделанное —
|
|
213
|
+
// ровно та жалоба, с которой пришёл первый чужой отзыв, только в другом месте программы.
|
|
214
|
+
const missing = !next ? ""
|
|
215
|
+
: next.needsProof ? L.doctor.levelUnproven(`${SELF} prove`)
|
|
216
|
+
: next.need || next.title || "";
|
|
217
|
+
level = { reached, top: steps.length - 1, missing };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let rules = null;
|
|
221
|
+
if (await exists(join(CWD, entry))) {
|
|
222
|
+
rules = countArbiters(await readFile(join(CWD, entry), "utf8"), ["человек", "human", "nobody"]);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let run = null;
|
|
226
|
+
const lastRun = join(CWD, TARGET_DIR, "last-run.md");
|
|
227
|
+
if (await exists(lastRun)) {
|
|
228
|
+
run = parseLastRun(await readFile(lastRun, "utf8"));
|
|
229
|
+
if (run) run.stale = runIsStale(run.when);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ratchets = [];
|
|
233
|
+
const dir = typeof man?.ratchets === "string" ? man.ratchets.trim() : "";
|
|
234
|
+
if (dir && (await exists(join(CWD, dir)))) {
|
|
235
|
+
const { readdir } = await import("node:fs/promises");
|
|
236
|
+
for (const f of (await readdir(join(CWD, dir))).filter((n) => n.endsWith(".txt")).sort()) {
|
|
237
|
+
const body = await readFile(join(CWD, dir, f), "utf8");
|
|
238
|
+
const count = body.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#")).length;
|
|
239
|
+
ratchets.push({ name: f.replace(/\.txt$/, ""), count });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Свод читается ЦЕЛИКОМ и дословно: пересказ был бы третьим списком рядом с двумя.
|
|
244
|
+
let fullPart = null;
|
|
245
|
+
if (full) {
|
|
246
|
+
const rows = commandRows(L).map((r) => ({
|
|
247
|
+
cmd: `${portableSelf()} ${r.name}${r.args ? ` ${r.args}` : ""}`,
|
|
248
|
+
text: r.text,
|
|
249
|
+
}));
|
|
250
|
+
let text = "";
|
|
251
|
+
if (rules !== null) { try { text = await readFile(join(CWD, entry), "utf8"); } catch { text = ""; } }
|
|
252
|
+
fullPart = { entry, rows, text };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log(contextBlock({
|
|
256
|
+
entry, entryExists: rules !== null, level, rules, run, ratchets, full: fullPart,
|
|
257
|
+
}).join("\n"));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { cmdContext, contextBlock, parseLastRun, countArbiters, withHook, hasOurHook, portableSelf };
|
package/tool/commands/doctor.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
|
|
7
7
|
import { CWD, PKG_ROOT, TARGET_DIR, SELF, c, exists, die } from "../lib/core.mjs";
|
|
8
|
-
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet } from "../lib/manifest.mjs";
|
|
8
|
+
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks } from "../lib/manifest.mjs";
|
|
9
9
|
import { proveGates } from "../lib/prove.mjs";
|
|
10
10
|
import { detectFacts, readCatalog, triggerVerdict, recipeFor } from "../lib/repo.mjs";
|
|
11
11
|
import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
|
|
@@ -126,11 +126,15 @@ function runGates(man, opts = {}) {
|
|
|
126
126
|
const t0 = Date.now();
|
|
127
127
|
const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
|
|
128
128
|
const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
|
|
129
|
+
// Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
|
|
130
|
+
// по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
|
|
131
|
+
// Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
|
|
132
|
+
const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
|
|
129
133
|
|
|
130
134
|
if (r.error && r.error.code === "ETIMEDOUT") {
|
|
131
135
|
console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
|
|
132
136
|
failed++;
|
|
133
|
-
results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout });
|
|
137
|
+
results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
|
|
134
138
|
continue;
|
|
135
139
|
}
|
|
136
140
|
const code = r.status;
|
|
@@ -142,7 +146,7 @@ function runGates(man, opts = {}) {
|
|
|
142
146
|
// Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
|
|
143
147
|
const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
|
|
144
148
|
for (const line of okAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
|
|
145
|
-
results.push({ name, cmd, ok: true, secs });
|
|
149
|
+
results.push({ name, cmd, ok: true, secs, out: outAll });
|
|
146
150
|
} else {
|
|
147
151
|
const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
|
|
148
152
|
// Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
|
|
@@ -163,14 +167,14 @@ function runGates(man, opts = {}) {
|
|
|
163
167
|
// выдать провал за тишину; остаётся красным, и причина названа.
|
|
164
168
|
console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.exitCode(code))} ${c.dim(`· ${L.doctor.notScopable}`)}`);
|
|
165
169
|
failed++;
|
|
166
|
-
results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable });
|
|
170
|
+
results.push({ name, cmd, ok: false, secs, code, note: L.doctor.notScopable, out: outAll });
|
|
167
171
|
continue;
|
|
168
172
|
}
|
|
169
173
|
if (s.findings === 0) {
|
|
170
174
|
// Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
|
|
171
175
|
// «всё хорошо» здесь было бы неправдой.
|
|
172
176
|
console.log(` ${c.green("✔")} ${name.padEnd(14)} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
|
|
173
|
-
results.push({ name, cmd, ok: true, secs, scopedAway: out.length });
|
|
177
|
+
results.push({ name, cmd, ok: true, secs, scopedAway: out.length, out: outAll });
|
|
174
178
|
continue;
|
|
175
179
|
}
|
|
176
180
|
out = s.kept;
|
|
@@ -191,7 +195,7 @@ function runGates(man, opts = {}) {
|
|
|
191
195
|
if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
|
|
192
196
|
// Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
|
|
193
197
|
for (const line of alwaysAdvice.slice(0, 6)) console.log(c.yellow(` ${line.trim().slice(0, 110)}`));
|
|
194
|
-
results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory });
|
|
198
|
+
results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
|
|
195
199
|
}
|
|
196
200
|
}
|
|
197
201
|
// Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
|
|
@@ -237,13 +241,10 @@ async function cmdDoctor() {
|
|
|
237
241
|
// а копия завтра разошлась бы с ними. Без этого различия `doctor` краснел на собственном
|
|
238
242
|
// репозитории и требовал разложить комплект в комплект.
|
|
239
243
|
const inKit = resolve(CWD) === resolve(PKG_ROOT);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
[".gitignore", L.doctor.gitignore],
|
|
245
|
-
[".git", L.doctor.git],
|
|
246
|
-
];
|
|
244
|
+
const man = await readManifest();
|
|
245
|
+
// Что именно проверять — решает манифест: где у ЭТОГО проекта правила, методички и точка
|
|
246
|
+
// входа. Литеральный список стоял здесь до 2026-09-08 и печатал кресты за сделанное.
|
|
247
|
+
const checks = layoutChecks(man, inKit);
|
|
247
248
|
|
|
248
249
|
let missing = 0;
|
|
249
250
|
for (const [path, what] of checks) {
|
|
@@ -252,8 +253,10 @@ async function cmdDoctor() {
|
|
|
252
253
|
console.log(` ${ok ? c.green("✔") : c.red("✘")} ${path.padEnd(22)} ${c.dim(what)}`);
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
// Команды в
|
|
256
|
-
|
|
256
|
+
// Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
|
|
257
|
+
// что проверен выше, — иначе проект на `CLAUDE.md` этой проверки не получал вовсе.
|
|
258
|
+
const entryFile = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim() || "AGENTS.md";
|
|
259
|
+
const agents = join(CWD, entryFile);
|
|
257
260
|
if (await exists(agents)) {
|
|
258
261
|
const text = await readFile(agents, "utf8");
|
|
259
262
|
const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
|
|
@@ -265,8 +268,6 @@ async function cmdDoctor() {
|
|
|
265
268
|
}
|
|
266
269
|
}
|
|
267
270
|
|
|
268
|
-
const man = await readManifest();
|
|
269
|
-
|
|
270
271
|
// Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
|
|
271
272
|
// Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
|
|
272
273
|
const unknown = unknownKeys(man);
|
|
@@ -316,6 +317,12 @@ async function cmdDoctor() {
|
|
|
316
317
|
|
|
317
318
|
const facts = await detectFacts(man);
|
|
318
319
|
if (process.argv.includes("--baseline")) {
|
|
320
|
+
// `--baseline` — осмотр, а не прогон: он выходит с нулём всегда. Совмещённый с `--run` или
|
|
321
|
+
// `--min` он давал конвейер, который НЕ МОЖЕТ покраснеть: порог назван, гейты не запущены,
|
|
322
|
+
// код нулевой. Человек, собравший такую строку, считает, что порог держится. Отказываемся
|
|
323
|
+
// вслух — молчаливое зелёное здесь дороже сломанной команды. Найдено ревью 2026-09-08.
|
|
324
|
+
const clash = ["--run", "--min"].filter((f) => process.argv.includes(f));
|
|
325
|
+
if (clash.length) die(L.doctor.baselineClash(clash.join(", ")));
|
|
319
326
|
await reportBaseline(man, facts);
|
|
320
327
|
process.exit(0);
|
|
321
328
|
}
|
|
@@ -359,4 +366,4 @@ async function cmdDoctor() {
|
|
|
359
366
|
|
|
360
367
|
// Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не
|
|
361
368
|
// импортирует, читается как «это часть договора» и мешает менять внутренности.
|
|
362
|
-
export { cmdDoctor, runGates, declaredGates };
|
|
369
|
+
export { cmdDoctor, runGates, declaredGates, sinceRef };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// tool/commands/learn.mjs — кандидаты в правила из локальных логов сессий.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Тезис комплекта: обещание обязано стать командой. Но сначала обещание обязано быть
|
|
4
|
+
// ЗАПИСАНО, а половина того, что человек требует от агента, живёт только в переписке. Здесь
|
|
5
|
+
// комплект смотрит туда, где эти требования лежат, и показывает те, которых нет в точке входа.
|
|
6
|
+
//
|
|
7
|
+
// ЧТО ИЗМЕРЕНО ДО КОДА (2026-09-08, 67 сессий на машине владельца):
|
|
8
|
+
// · 25 353 записи `user` — из них человеком напечатано 1912. Остальное результаты
|
|
9
|
+
// инструментов. Отличает их поле `promptSource: "typed"`, и оно точнее любой эвристики:
|
|
10
|
+
// первая версия отбирала по длине и языку и выдавала «agent quality kit» 44 раза — то есть
|
|
11
|
+
// вставленные пути, а не правила;
|
|
12
|
+
// · из 1619 уникальных напечатанных реплик маркеры наставления дают 79, это 4%. Среди них
|
|
13
|
+
// настоящие правила («файл не трогай», «делай прогон с базой обязательно», «никаких
|
|
14
|
+
// обходных временных путей») и разговорная шелуха примерно поровну.
|
|
15
|
+
//
|
|
16
|
+
// ЧЕГО ЗДЕСЬ НАМЕРЕННО НЕТ. Поиска ПОВТОРОВ — приёма, на котором построен session-analyzer у
|
|
17
|
+
// agent-lint. Замер его не подтвердил: на 67 сессиях владелец не повторяет правило дословно, он
|
|
18
|
+
// говорит его один раз и каждый раз иначе. Те «повторы», что нашлись, оказались задвоением
|
|
19
|
+
// одной реплики в самом логе.
|
|
20
|
+
//
|
|
21
|
+
// ПРИВАТНОСТЬ. Команда читает переписку. Поэтому: только логи ТЕКУЩЕГО проекта (или явно
|
|
22
|
+
// названного), только в терминал, ни строки на диск, код возврата всегда 0. Отчёт, который
|
|
23
|
+
// можно закоммитить, из переписки не собирается — это решение, а не недоделка.
|
|
24
|
+
|
|
25
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { CWD, c, exists } from "../lib/core.mjs";
|
|
29
|
+
import { readManifest } from "../lib/manifest.mjs";
|
|
30
|
+
import { L } from "../i18n/index.mjs";
|
|
31
|
+
|
|
32
|
+
// Каталог логов зовётся по рабочему пути, где всё, кроме букв и цифр, заменено на дефис.
|
|
33
|
+
// Правило снято с живой машины, а не угадано: /home/ser/projects/audit_project лежит в
|
|
34
|
+
// -home-ser-projects-audit-project, то есть подчёркивание тоже становится дефисом.
|
|
35
|
+
function logSlug(cwd) {
|
|
36
|
+
return String(cwd).toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Маркеры наставления на двух языках. Список короткий намеренно: каждый лишний маркер добавляет
|
|
40
|
+
// шума больше, чем находок, а разбирать этот список человеку.
|
|
41
|
+
const MARKERS = new RegExp(
|
|
42
|
+
"(всегда|никогда|не надо|не нужно|обязательно|запомни|больше не|каждый раз|нельзя|" +
|
|
43
|
+
"только после|перед тем|сначала|не забывай|не трогай|как договорились|" +
|
|
44
|
+
"always|never|don'?t|do not|make sure|remember to|must not)",
|
|
45
|
+
"i",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Признаки вставки, а не реплики: длина, код в тройных кавычках, много переносов, пути, ссылки.
|
|
49
|
+
// Каждый добавлен по итогу прогона, а не на всякий случай.
|
|
50
|
+
function looksLikeRule(text) {
|
|
51
|
+
const t = String(text || "").trim();
|
|
52
|
+
if (!t || t.length > 400) return false;
|
|
53
|
+
if (t.includes("```")) return false;
|
|
54
|
+
if ((t.match(/\n/g) || []).length > 6) return false;
|
|
55
|
+
if (/https?:\/\//.test(t)) return false;
|
|
56
|
+
if ((t.match(/\S+\/\S+/g) || []).length >= 3) return false;
|
|
57
|
+
return MARKERS.test(t);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Слова, по которым сверяем сказанное с записанным. Короткие отброшены: на них совпадёт что
|
|
61
|
+
// угодно, и любое правило показалось бы уже записанным — то есть команда молчала бы всегда.
|
|
62
|
+
function keyWords(text) {
|
|
63
|
+
return [...new Set(String(text).toLowerCase().match(/[а-яёa-z]{4,}/g) || [])];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Сверяем по ОСНОВЕ, а не по слову целиком. Русский язык склоняет: в реплике «локальный
|
|
67
|
+
// костыль», в своде «до местного костыля» — по целому слову это промах, и правило, записанное
|
|
68
|
+
// час назад, показалось бы незаписанным. Проверено на живом логе: без основы первым же пунктом
|
|
69
|
+
// вышло правило, внесённое в AGENTS.md в тот же день.
|
|
70
|
+
function stem(w) {
|
|
71
|
+
return w.length > 5 ? w.slice(0, 5) : w;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Сказано вслух и НЕ записано. Порог половинный: правило, у которого хотя бы половина значимых
|
|
75
|
+
// слов уже стоит в точке входа, считаем записанным — иначе команда повторяла бы владельцу его
|
|
76
|
+
// же свод. Порог назван здесь, а не спрятан: он произвольный, и это видно.
|
|
77
|
+
function saidNotWritten(text, entryText) {
|
|
78
|
+
const words = keyWords(text);
|
|
79
|
+
if (!words.length) return false;
|
|
80
|
+
const hay = String(entryText || "").toLowerCase();
|
|
81
|
+
const hit = words.filter((w) => hay.includes(stem(w))).length;
|
|
82
|
+
return hit / words.length < 0.5;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Напечатанные человеком реплики одной сессии. Всё прочее — результаты инструментов, служебные
|
|
86
|
+
// вставки и подсказки — отбрасывается по полю promptSource.
|
|
87
|
+
function typedFrom(jsonl) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const line of jsonl.split("\n")) {
|
|
90
|
+
if (!line.trim()) continue;
|
|
91
|
+
let d;
|
|
92
|
+
try { d = JSON.parse(line); } catch { continue; }
|
|
93
|
+
if (d?.type !== "user" || d?.promptSource !== "typed") continue;
|
|
94
|
+
const cont = d?.message?.content;
|
|
95
|
+
const text = typeof cont === "string"
|
|
96
|
+
? cont
|
|
97
|
+
: Array.isArray(cont)
|
|
98
|
+
? cont.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ")
|
|
99
|
+
: "";
|
|
100
|
+
const t = String(text).replace(/\s+/g, " ").trim();
|
|
101
|
+
if (t) out.push({ text: t, when: String(d.timestamp || "").slice(0, 10) });
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readEntry(man) {
|
|
107
|
+
const names = Array.isArray(man?.entry) && man.entry.length ? man.entry : ["AGENTS.md", "CLAUDE.md"];
|
|
108
|
+
let all = "";
|
|
109
|
+
for (const n of names) {
|
|
110
|
+
try { all += `\n${await readFile(join(CWD, String(n)), "utf8")}`; } catch { /* нет файла — не беда */ }
|
|
111
|
+
}
|
|
112
|
+
return all;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function cmdLearn(argv = process.argv) {
|
|
116
|
+
const limitAt = argv.indexOf("--limit");
|
|
117
|
+
const limit = limitAt !== -1 && /^\d+$/.test(argv[limitAt + 1] || "") ? Number(argv[limitAt + 1]) : 20;
|
|
118
|
+
const root = join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects", logSlug(CWD));
|
|
119
|
+
|
|
120
|
+
console.log(c.bold(`\n ${L.learn.title}\n`));
|
|
121
|
+
if (!(await exists(root))) {
|
|
122
|
+
console.log(` ${L.learn.noLogs(root)}\n`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let files = [];
|
|
127
|
+
try { files = (await readdir(root)).filter((f) => f.endsWith(".jsonl")); } catch { files = []; }
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const said = [];
|
|
130
|
+
let typedTotal = 0;
|
|
131
|
+
for (const f of files) {
|
|
132
|
+
let raw = "";
|
|
133
|
+
try { raw = await readFile(join(root, f), "utf8"); } catch { continue; }
|
|
134
|
+
for (const m of typedFrom(raw)) {
|
|
135
|
+
typedTotal++;
|
|
136
|
+
const key = m.text.toLowerCase().slice(0, 200);
|
|
137
|
+
if (seen.has(key)) continue;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
if (looksLikeRule(m.text)) said.push(m);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const entry = await readEntry(await readManifest());
|
|
144
|
+
const fresh = said.filter((m) => saidNotWritten(m.text, entry));
|
|
145
|
+
fresh.sort((a, b) => String(b.when).localeCompare(String(a.when)));
|
|
146
|
+
|
|
147
|
+
console.log(` ${c.dim(L.learn.counted(files.length, typedTotal, said.length, fresh.length))}\n`);
|
|
148
|
+
if (!fresh.length) {
|
|
149
|
+
console.log(` ${L.learn.nothing}\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
for (const m of fresh.slice(0, limit)) {
|
|
153
|
+
console.log(` ${c.dim(m.when)} ${m.text.slice(0, 150)}`);
|
|
154
|
+
}
|
|
155
|
+
if (fresh.length > limit) console.log(c.dim(`\n ${L.learn.andMore(fresh.length - limit)}`));
|
|
156
|
+
console.log(`\n ${c.yellow(L.learn.warn)}\n`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { cmdLearn, logSlug, looksLikeRule, saidNotWritten, typedFrom };
|
package/tool/commands/report.mjs
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { mkdir, writeFile, readdir, readFile } from "node:fs/promises";
|
|
17
17
|
import { join, relative } from "node:path";
|
|
18
|
+
import { statSync } from "node:fs";
|
|
18
19
|
import { CWD, TARGET_DIR, SELF, c, exists, docPath } from "../lib/core.mjs";
|
|
19
20
|
import { readManifest, assessLevel } from "../lib/manifest.mjs";
|
|
20
21
|
import { proveGates } from "../lib/prove.mjs";
|
|
21
22
|
import { detectFacts, readCatalog, triggerVerdict, whichSync } from "../lib/repo.mjs";
|
|
22
|
-
import {
|
|
23
|
+
import { changedCode, coverage, evidenceHash, readForHash } from "../lib/evidence.mjs";
|
|
24
|
+
import { runGates, declaredGates, sinceRef } from "./doctor.mjs";
|
|
23
25
|
import { L } from "../i18n/index.mjs";
|
|
24
26
|
|
|
25
27
|
// Каким рецептом стоит гейт: родным инструментом или переносимой проверкой. Именно это
|
|
@@ -175,6 +177,36 @@ async function cmdReport() {
|
|
|
175
177
|
say(`> ${L.report2.ignoreWarn}`);
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// --- чем доказан этот диф -------------------------------------------------
|
|
181
|
+
// Раздел появляется только с `--since`: без базы сравнения говорить о покрытии нечего, а
|
|
182
|
+
// молчаливо взять умолчание нельзя — «сравнили не с тем» неотличимо от «всё покрыто».
|
|
183
|
+
const since = sinceRef();
|
|
184
|
+
if (since) {
|
|
185
|
+
const changed = changedCode(since, CWD);
|
|
186
|
+
say("");
|
|
187
|
+
say(`## ${L.report2.evidenceTitle}`);
|
|
188
|
+
say("");
|
|
189
|
+
if (changed === null) {
|
|
190
|
+
say(`- ⚠️ ${L.report2.evidenceBadRef(since)}`);
|
|
191
|
+
} else if (!changed.length) {
|
|
192
|
+
say(`- ${L.report2.evidenceNoFiles(since)}`);
|
|
193
|
+
} else {
|
|
194
|
+
// Каталог ли это — спрашиваем у диска: цель гейта «tool» и файл «tool.js» иначе
|
|
195
|
+
// неразличимы, и второй попал бы в «просмотрен» ни за что.
|
|
196
|
+
const isDir = (rel) => { try { return statSync(join(CWD, rel)).isDirectory(); } catch { return false; } };
|
|
197
|
+
const cov = coverage(changed, run.results, isDir);
|
|
198
|
+
const hash = evidenceHash(since, run.results, readForHash(changed, CWD));
|
|
199
|
+
for (const [f, by] of cov.covered) say(`- ✅ ${f} — ${L.report2.evidenceNamed(by.join(", "))}`);
|
|
200
|
+
for (const [f, by] of cov.silent) say(`- ◻️ ${f} — ${L.report2.evidenceSilent(by.length)}`);
|
|
201
|
+
for (const f of cov.uncovered) say(`- ❌ ${f} — ${L.report2.evidenceUncovered}`);
|
|
202
|
+
say("");
|
|
203
|
+
say(`- ${L.report2.evidenceBase}: \`${since}\``);
|
|
204
|
+
say(`- ${L.report2.evidenceHash}: \`${hash}\``);
|
|
205
|
+
say("");
|
|
206
|
+
say(`> ${L.report2.evidenceWarn}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
178
210
|
say("");
|
|
179
211
|
say(`## ${L.report2.whyTitle}`);
|
|
180
212
|
say("");
|