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
package/tool/commands/doctor.mjs
CHANGED
|
@@ -4,12 +4,13 @@ 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
6
|
import { scopeOutput, splitAdvice, 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, advisorySet } from "../lib/manifest.mjs";
|
|
7
|
+
import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die } from "../lib/core.mjs";
|
|
8
|
+
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, advisorySet, layoutChecks, coversOf, coversUnproven, unparsedLines } from "../lib/manifest.mjs";
|
|
9
9
|
import { proveGates } from "../lib/prove.mjs";
|
|
10
|
-
import { detectFacts, readCatalog, triggerVerdict,
|
|
10
|
+
import { detectFacts, readCatalog, triggerVerdict, browserServerAdvice } from "../lib/repo.mjs";
|
|
11
11
|
import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
|
|
12
12
|
import { L } from "../i18n/index.mjs";
|
|
13
|
+
import { beginBrief, finishBrief } from "../lib/brief.mjs";
|
|
13
14
|
|
|
14
15
|
// Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
|
|
15
16
|
// место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
|
|
@@ -50,11 +51,16 @@ async function reportCatalog(man, facts) {
|
|
|
50
51
|
const catalog = await readCatalog();
|
|
51
52
|
if (!catalog.length) return;
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
// Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
|
|
55
|
+
// Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
|
|
56
|
+
// ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
|
|
57
|
+
const { covered, unknownGates } = coversOf(man);
|
|
58
|
+
const held = [], todo = [], skip = [], byOther = [];
|
|
54
59
|
for (const rec of catalog) {
|
|
55
60
|
const v = triggerVerdict(rec, facts);
|
|
56
61
|
if (!v.applies) skip.push([rec, v.why]);
|
|
57
62
|
else if (facts.gateKeys.includes(rec.slug)) held.push(rec);
|
|
63
|
+
else if (covered.has(rec.slug)) byOther.push([rec, covered.get(rec.slug)]);
|
|
58
64
|
else todo.push(rec);
|
|
59
65
|
}
|
|
60
66
|
|
|
@@ -72,14 +78,50 @@ async function reportCatalog(man, facts) {
|
|
|
72
78
|
console.log(` ${c.yellow("✘")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
|
|
73
79
|
console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
|
|
74
80
|
}
|
|
81
|
+
if (byOther.length) {
|
|
82
|
+
console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
|
|
83
|
+
for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
|
|
84
|
+
}
|
|
85
|
+
// Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
|
|
86
|
+
// считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
|
|
87
|
+
if (unknownGates.length) {
|
|
88
|
+
console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
|
|
89
|
+
}
|
|
90
|
+
// Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
|
|
91
|
+
// Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
|
|
92
|
+
// сняла бы запись с долга, не закрыв её ничем.
|
|
93
|
+
let linterCfg = "";
|
|
94
|
+
for (const f of ["ruff.toml", ".ruff.toml", "pyproject.toml", ".eslintrc.json", "eslint.config.js", "eslint.config.mjs", "biome.json"]) {
|
|
95
|
+
try { linterCfg += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
|
|
96
|
+
}
|
|
97
|
+
const unproven = coversUnproven(man, catalog, linterCfg);
|
|
98
|
+
for (const u of unproven) {
|
|
99
|
+
console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
|
|
100
|
+
console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
|
|
101
|
+
}
|
|
102
|
+
// Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
|
|
103
|
+
// Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
|
|
104
|
+
let mcpText = "";
|
|
105
|
+
for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
|
|
106
|
+
try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
|
|
107
|
+
}
|
|
108
|
+
const browser = browserServerAdvice(facts, mcpText);
|
|
109
|
+
if (browser) {
|
|
110
|
+
console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
|
|
111
|
+
console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
|
|
112
|
+
}
|
|
75
113
|
if (skip.length) {
|
|
76
114
|
console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
|
|
77
115
|
for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
|
|
78
116
|
}
|
|
79
117
|
console.log(
|
|
80
118
|
`\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
|
|
119
|
+
(byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
|
|
81
120
|
c.dim(L.doctor.totalSkip(skip.length)) + "\n"
|
|
82
121
|
);
|
|
122
|
+
// Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
|
|
123
|
+
// расходятся ровно так же, как два списка команд.
|
|
124
|
+
return { held: held.length, todo: todo.length, todoRecs: todo };
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
// «Гейт объявлен» и «гейт работает» — разные утверждения. Первое читается из манифеста,
|
|
@@ -126,23 +168,33 @@ function runGates(man, opts = {}) {
|
|
|
126
168
|
const t0 = Date.now();
|
|
127
169
|
const r = spawnSync(cmd, { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
|
|
128
170
|
const secs = (Math.max(0, Date.now() - t0) / 1000).toFixed(1);
|
|
171
|
+
// Вывод гейта запоминается целиком (с потолком, чтобы болтливый инструмент не съел память):
|
|
172
|
+
// по нему считается покрытие дифа — какой файл вообще был назван хоть одной проверкой.
|
|
173
|
+
// Без этого «готово = доказано» остаётся правилом, за которым следит только человек.
|
|
174
|
+
const outAll = `${r.stdout || ""}${r.stderr || ""}`.slice(0, 200000);
|
|
129
175
|
|
|
130
176
|
if (r.error && r.error.code === "ETIMEDOUT") {
|
|
131
177
|
console.log(` ${c.red("✘")} ${name.padEnd(14)} ${c.red(L.doctor.timeout)}`);
|
|
132
178
|
failed++;
|
|
133
|
-
results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout });
|
|
179
|
+
results.push({ name, cmd, ok: false, secs, note: L.doctor.timeout, out: outAll });
|
|
134
180
|
continue;
|
|
135
181
|
}
|
|
136
182
|
const code = r.status;
|
|
137
183
|
if (code === 0) {
|
|
138
|
-
|
|
184
|
+
// Совещательный называется и когда он зелёный. Иначе гейт, который уронить прогон НЕ
|
|
185
|
+
// МОЖЕТ, по выводу неотличим от того, который может, — и список `advisory:` в манифесте
|
|
186
|
+
// виден только в тот день, когда он покраснел. Измерено 2026-09-09: зелёный
|
|
187
|
+
// совещательный печатался обычной галочкой, а README обещал, что список назван каждый
|
|
188
|
+
// прогон. Тот же класс, что молчащий гейт, только про сам прибор.
|
|
189
|
+
const quiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
|
|
190
|
+
console.log(` ${c.green("✔")} ${name.padEnd(14)}${quiet} ${c.dim(`${secs}s · ${cmd}`)}`);
|
|
139
191
|
// Зелёный гейт иногда всё-таки говорит человеку что-то важное: храповик, дошедший до цели,
|
|
140
192
|
// просит убрать обёртку. Вывод успешного гейта не показывался вовсе, и это сообщение
|
|
141
193
|
// уходило в никуда — тот же класс, что обрезанный совет у красного, только тише.
|
|
142
194
|
// Показываем ровно строки с меткой совета: остальной вывод успешной проверки — шум.
|
|
143
195
|
const okAdvice = splitAdvice(`${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean)).advice;
|
|
144
196
|
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 });
|
|
197
|
+
results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), out: outAll });
|
|
146
198
|
} else {
|
|
147
199
|
const raw = `${r.stdout || ""}${r.stderr || ""}`.trim().split("\n").filter(Boolean);
|
|
148
200
|
// Совет отделяется ДО сужения. Иначе он сам попадает под фильтр по путям: сообщение
|
|
@@ -161,16 +213,23 @@ function runGates(man, opts = {}) {
|
|
|
161
213
|
if (!s.scopable || out.length === 0) {
|
|
162
214
|
// Гейт печатает вердикт без путей — сузить нечем. Признать его успешным значило бы
|
|
163
215
|
// выдать провал за тишину; остаётся красным, и причина названа.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
216
|
+
// Совещательный не роняет прогон НИКОГДА — в том числе здесь. Раньше failed++ стоял
|
|
217
|
+
// безусловно, и гейт, объявленный совещательным, валил сборку с `--since` только
|
|
218
|
+
// потому, что в его выводе нет путей. Измерено 2026-09-09.
|
|
219
|
+
const nsAdv = advisory.has(name);
|
|
220
|
+
const nsMark = nsAdv ? c.yellow("!") : c.red("✘");
|
|
221
|
+
const nsVerdict = nsAdv ? c.yellow(L.doctor.advisoryMark) : c.red(L.doctor.exitCode(code));
|
|
222
|
+
console.log(` ${nsMark} ${name.padEnd(14)} ${nsVerdict} ${c.dim(`· ${L.doctor.notScopable}`)}`);
|
|
223
|
+
if (!nsAdv) failed++;
|
|
224
|
+
results.push({ name, cmd, ok: false, secs, code, advisory: nsAdv, note: L.doctor.notScopable, out: outAll });
|
|
167
225
|
continue;
|
|
168
226
|
}
|
|
169
227
|
if (s.findings === 0) {
|
|
170
228
|
// Долг есть, но не в том, что внёс диф. Зелёный — но с числом спрятанного: молчаливое
|
|
171
229
|
// «всё хорошо» здесь было бы неправдой.
|
|
172
|
-
|
|
173
|
-
|
|
230
|
+
const sQuiet = advisory.has(name) ? ` ${c.yellow(L.doctor.advisoryQuiet)}` : "";
|
|
231
|
+
console.log(` ${c.green("✔")} ${name.padEnd(14)}${sQuiet} ${c.dim(`${secs}s · ${L.doctor.outsideDiff(out.length)}`)}`);
|
|
232
|
+
results.push({ name, cmd, ok: true, secs, advisory: advisory.has(name), scopedAway: out.length, out: outAll });
|
|
174
233
|
continue;
|
|
175
234
|
}
|
|
176
235
|
out = s.kept;
|
|
@@ -191,12 +250,12 @@ function runGates(man, opts = {}) {
|
|
|
191
250
|
if (out.length > 3) console.log(c.dim(` ${L.doctor.moreLines(out.length - 3)}`));
|
|
192
251
|
// Совет тоже не бесконечен: гейт, зовущий помощник шесть раз, печатает его шесть раз.
|
|
193
252
|
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 });
|
|
253
|
+
results.push({ name, cmd, ok: false, secs, code, advisory: isAdvisory, out: outAll });
|
|
195
254
|
}
|
|
196
255
|
}
|
|
197
256
|
// Совещательные, которые покраснели, называются вслух ВСЕГДА. Молчание о них — ровно та
|
|
198
257
|
// тишина, против которой построен стандарт: проверка выключена, а выглядит как её отсутствие.
|
|
199
|
-
const advisoryFailed = results.filter((x) => x.advisory).map((x) => x.name);
|
|
258
|
+
const advisoryFailed = results.filter((x) => x.advisory && !x.ok).map((x) => x.name);
|
|
200
259
|
if (advisoryFailed.length) console.log(`\n ${c.yellow(L.doctor.advisorySummary(advisoryFailed))}`);
|
|
201
260
|
return { failed, ran: gates.length, results, advisoryFailed };
|
|
202
261
|
}
|
|
@@ -224,6 +283,8 @@ async function writeRunReport({ version, reached, results }) {
|
|
|
224
283
|
}
|
|
225
284
|
|
|
226
285
|
async function cmdDoctor() {
|
|
286
|
+
const brief = process.argv.includes("--brief");
|
|
287
|
+
const buf = brief ? beginBrief() : null;
|
|
227
288
|
// Версия в шапке — единственное, что привязывает баг-репорт к коммиту, если ставили не из
|
|
228
289
|
// релиза: без неё "у меня не работает" ничем не отличается от любой другой версии за год.
|
|
229
290
|
let version = "";
|
|
@@ -237,13 +298,10 @@ async function cmdDoctor() {
|
|
|
237
298
|
// а копия завтра разошлась бы с ними. Без этого различия `doctor` краснел на собственном
|
|
238
299
|
// репозитории и требовал разложить комплект в комплект.
|
|
239
300
|
const inKit = resolve(CWD) === resolve(PKG_ROOT);
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
[".gitignore", L.doctor.gitignore],
|
|
245
|
-
[".git", L.doctor.git],
|
|
246
|
-
];
|
|
301
|
+
const man = await readManifest();
|
|
302
|
+
// Что именно проверять — решает манифест: где у ЭТОГО проекта правила, методички и точка
|
|
303
|
+
// входа. Литеральный список стоял здесь до 2026-09-08 и печатал кресты за сделанное.
|
|
304
|
+
const checks = layoutChecks(man, inKit);
|
|
247
305
|
|
|
248
306
|
let missing = 0;
|
|
249
307
|
for (const [path, what] of checks) {
|
|
@@ -252,8 +310,10 @@ async function cmdDoctor() {
|
|
|
252
310
|
console.log(` ${ok ? c.green("✔") : c.red("✘")} ${path.padEnd(22)} ${c.dim(what)}`);
|
|
253
311
|
}
|
|
254
312
|
|
|
255
|
-
// Команды в
|
|
256
|
-
|
|
313
|
+
// Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
|
|
314
|
+
// что проверен выше, — иначе проект на `CLAUDE.md` этой проверки не получал вовсе.
|
|
315
|
+
const entryFile = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim() || "AGENTS.md";
|
|
316
|
+
const agents = join(CWD, entryFile);
|
|
257
317
|
if (await exists(agents)) {
|
|
258
318
|
const text = await readFile(agents, "utf8");
|
|
259
319
|
const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
|
|
@@ -265,10 +325,17 @@ async function cmdDoctor() {
|
|
|
265
325
|
}
|
|
266
326
|
}
|
|
267
327
|
|
|
268
|
-
const man = await readManifest();
|
|
269
|
-
|
|
270
328
|
// Опечатка в имени поля означала «поля нет»: вердикт выдавался неверный, а причина молчала.
|
|
271
329
|
// Называем поле и говорим, какие бывают — иначе человек ищет ошибку в проекте, а она в файле.
|
|
330
|
+
// Строка, которую разбор не понял, называется ПЕРВОЙ и жёлтым: человек видит проверку в
|
|
331
|
+
// файле, а её не существует. До 2026-09-09 такая строка исчезала без слова — найдено
|
|
332
|
+
// случайно, гейтом с кириллическим именем, который «прошёл», не запустившись.
|
|
333
|
+
try {
|
|
334
|
+
const bad = unparsedLines(await readFile(join(CWD, MANIFEST), "utf8"));
|
|
335
|
+
for (const b of bad) console.log(c.yellow(`\n ${L.doctor.manifestUnparsed(b.line, b.text)}`));
|
|
336
|
+
if (bad.length) console.log(c.dim(` ${L.doctor.manifestUnparsedWhy}`));
|
|
337
|
+
} catch { /* манифеста нет — про строки в нём говорить нечего */ }
|
|
338
|
+
|
|
272
339
|
const unknown = unknownKeys(man);
|
|
273
340
|
if (unknown.length) {
|
|
274
341
|
console.log(c.yellow(`\n ${L.doctor.manifestUnknown(unknown)}`));
|
|
@@ -316,10 +383,16 @@ async function cmdDoctor() {
|
|
|
316
383
|
|
|
317
384
|
const facts = await detectFacts(man);
|
|
318
385
|
if (process.argv.includes("--baseline")) {
|
|
386
|
+
// `--baseline` — осмотр, а не прогон: он выходит с нулём всегда. Совмещённый с `--run` или
|
|
387
|
+
// `--min` он давал конвейер, который НЕ МОЖЕТ покраснеть: порог назван, гейты не запущены,
|
|
388
|
+
// код нулевой. Человек, собравший такую строку, считает, что порог держится. Отказываемся
|
|
389
|
+
// вслух — молчаливое зелёное здесь дороже сломанной команды. Найдено ревью 2026-09-08.
|
|
390
|
+
const clash = ["--run", "--min"].filter((f) => process.argv.includes(f));
|
|
391
|
+
if (clash.length) die(L.doctor.baselineClash(clash.join(", ")));
|
|
319
392
|
await reportBaseline(man, facts);
|
|
320
393
|
process.exit(0);
|
|
321
394
|
}
|
|
322
|
-
await reportCatalog(man, facts);
|
|
395
|
+
const cat = (await reportCatalog(man, facts)) || { held: 0, todo: 0, todoRecs: [] };
|
|
323
396
|
|
|
324
397
|
// «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
|
|
325
398
|
const wantRun = process.argv.includes("--run");
|
|
@@ -352,11 +425,14 @@ async function cmdDoctor() {
|
|
|
352
425
|
else if (!levelOk) line = c.red(` ${L.doctor.thresholdFail(min, now)}\n`);
|
|
353
426
|
else line = c.red(` ${L.doctor.thresholdGateFail(min, now, failedNames)}\n`);
|
|
354
427
|
console.log(line);
|
|
428
|
+
await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: failedNames ? String(failedNames).split(", ").filter(Boolean) : [] }, cat.todoRecs, pass);
|
|
355
429
|
process.exit(pass ? 0 : 1);
|
|
356
430
|
}
|
|
357
|
-
|
|
431
|
+
const ok = !(missing || reached < 0 || gateFailed);
|
|
432
|
+
await finishBrief(buf, { held: cat.held, todo: cat.todo, level: reached, red: [] }, cat.todoRecs, ok);
|
|
433
|
+
process.exit(ok ? 0 : 1);
|
|
358
434
|
}
|
|
359
435
|
|
|
360
436
|
// Наружу — только команда. Остальное здесь же и используется: экспорт, который никто не
|
|
361
437
|
// импортирует, читается как «это часть договора» и мешает менять внутренности.
|
|
362
|
-
export { cmdDoctor, runGates, declaredGates };
|
|
438
|
+
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 };
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
CWD, PKG_ROOT, DOCS_SRC, RULES_SRC, TARGET_DIR, MANIFEST, SELF, REPO_URL, c, exists, die,
|
|
9
9
|
copyDir, writeIfAbsent, FEEDBACK_MARK, docPath } from "../lib/core.mjs";
|
|
10
10
|
import { AGENTS_MD, CLAUDE_MD, MANIFEST_YML } from "../lib/templates.mjs";
|
|
11
|
+
import { banner } from "../lib/banner.mjs";
|
|
11
12
|
import { readManifest } from "../lib/manifest.mjs";
|
|
12
13
|
import { detectFacts, readCatalog, triggerVerdict } from "../lib/repo.mjs";
|
|
13
14
|
import { installGate } from "./gates.mjs";
|
|
@@ -40,7 +41,9 @@ async function cmdInit(args) {
|
|
|
40
41
|
const claude = join(CWD, "CLAUDE.md");
|
|
41
42
|
track(await writeIfAbsent(claude, CLAUDE_MD, { force }), claude);
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// Заставка в начале init — первая встреча человека с комплектом. Второй раз он увидит её
|
|
45
|
+
// только если сам спросит `--version`: то, что видишь тридцатый раз, перестаёт читаться.
|
|
46
|
+
console.log(`\n${banner()}\n`);
|
|
44
47
|
if (created.length) {
|
|
45
48
|
console.log(c.green(` ${L.init.created(created.length)}`));
|
|
46
49
|
for (const f of created.slice(0, 8)) console.log(` ${f}`);
|
|
@@ -76,6 +79,7 @@ ${c.bold(L.init.nextTitle)}
|
|
|
76
79
|
4. ${L.init.n4a} ${c.bold(L.init.n4b)}${L.init.n4c}
|
|
77
80
|
${L.init.n4d}
|
|
78
81
|
|
|
82
|
+
${c.dim(L.init.hookHint(`${SELF} context --install`))}
|
|
79
83
|
${c.dim(L.init.burned(`${SELF} note "…"`))}
|
|
80
84
|
`);
|
|
81
85
|
await maybeAskFeedback();
|
|
@@ -95,7 +99,20 @@ ${c.bold(L.feedback.title)}
|
|
|
95
99
|
${url}/issues/new
|
|
96
100
|
${c.dim(` ${L.feedback.once}`)}
|
|
97
101
|
`);
|
|
98
|
-
|
|
102
|
+
// Пометка «уже показывали» — удобство, а не работа команды. Домашнего каталога может не быть
|
|
103
|
+
// записываемым вовсе: в контейнере, запущенном `--user 1001:127`, у этого uid нет записи в
|
|
104
|
+
// /etc/passwd, `homedir()` даёт «/», и запись падает с EACCES на `/.config`. До 2026-09-09
|
|
105
|
+
// это роняло ВЕСЬ `init` — то есть любого, кто набрал команду из нашей же документации по
|
|
106
|
+
// docker. Локально не воспроизводилось случайно: uid разработчика 1000 совпадает с
|
|
107
|
+
// пользователем `node` в образе, у которого дом есть. Нашёл конвейер, где uid 1001.
|
|
108
|
+
//
|
|
109
|
+
// Молча глотать нельзя — это то, что красит наш же swallowed-error. Поэтому вслух: не
|
|
110
|
+
// запомнили, покажем снова. Установка при этом доходит до конца.
|
|
111
|
+
try {
|
|
112
|
+
await writeIfAbsent(FEEDBACK_MARK, "shown\n", { force: false });
|
|
113
|
+
} catch {
|
|
114
|
+
console.log(c.dim(` ${L.feedback.notRemembered}`));
|
|
115
|
+
}
|
|
99
116
|
}
|
|
100
117
|
|
|
101
118
|
function findJournal() {
|
package/tool/commands/prove.mjs
CHANGED
|
@@ -17,6 +17,7 @@ function line(r) {
|
|
|
17
17
|
r.why === "no-samples" ? P.noSamples
|
|
18
18
|
: r.why === "other-recipe" ? P.otherRecipe(r.forRecipe.lang)
|
|
19
19
|
: r.why === "no-target" ? P.noTarget
|
|
20
|
+
: r.why === "needs-program" ? P.needsProgram(r.missing.join(", "))
|
|
20
21
|
: P.empty;
|
|
21
22
|
return ` ${c.dim("~")} ${c.dim(pad)} ${c.dim(why)}`;
|
|
22
23
|
}
|
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("");
|