agent-quality-kit 0.13.0 → 0.15.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 +46 -8
- package/README.ru.md +49 -9
- package/kit/gates/api-contract-has-arbiter/README.md +16 -1
- package/kit/gates/api-contract-has-arbiter/check.sh +66 -23
- package/kit/gates/entry-commands-exist/README.md +64 -0
- package/kit/gates/entry-commands-exist/check.sh +110 -0
- package/kit/gates/entry-commands-exist/gate.yml +19 -0
- package/kit/gates/entry-commands-exist/green/AGENTS.md +13 -0
- package/kit/gates/entry-commands-exist/green/Makefile +6 -0
- package/kit/gates/entry-commands-exist/green/justfile +2 -0
- package/kit/gates/entry-commands-exist/green/package.json +10 -0
- package/kit/gates/entry-commands-exist/red/AGENTS.md +9 -0
- package/kit/gates/entry-commands-exist/red/Makefile +2 -0
- package/kit/gates/entry-commands-exist/red/package.json +9 -0
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/tool/commands/context.mjs +59 -44
- package/tool/commands/doctor-catalog.mjs +222 -0
- package/tool/commands/doctor.mjs +46 -237
- package/tool/commands/feedback.mjs +157 -0
- package/tool/commands/learn.mjs +119 -19
- package/tool/commands/probe.mjs +4 -2
- package/tool/commands/project.mjs +11 -13
- package/tool/commands/prompt.mjs +70 -0
- package/tool/i18n/en-docs.mjs +1 -0
- package/tool/i18n/en-gates.mjs +27 -0
- package/tool/i18n/en.mjs +70 -3
- package/tool/i18n/index.mjs +42 -3
- package/tool/i18n/ru-docs.mjs +1 -0
- package/tool/i18n/ru-gates.mjs +28 -0
- package/tool/i18n/ru.mjs +81 -3
- package/tool/lib/annotate.mjs +66 -0
- package/tool/lib/ask.mjs +118 -0
- package/tool/lib/brief.mjs +17 -38
- package/tool/lib/cadence.mjs +30 -1
- package/tool/lib/core.mjs +8 -6
- package/tool/lib/gate-worker.mjs +4 -1
- package/tool/lib/repo.mjs +45 -4
- package/tool/lib/run.mjs +140 -9
- package/tool/program.mjs +10 -0
- package/tool/selfcheck/smoke/_fixture.mjs +8 -3
- package/tool/selfcheck/smoke/api-contract.test.mjs +37 -0
- package/tool/selfcheck/smoke/corpus.test.mjs +151 -0
- package/tool/selfcheck/smoke/fail-closed.test.mjs +96 -1
- package/tool/selfcheck/smoke/first-run.test.mjs +39 -0
- package/tool/selfcheck/smoke/verdict.test.mjs +41 -2
- package/tool/selfcheck/smoke.sh +4 -0
- package/tool/selfcheck/units-annotate.mjs +67 -0
- package/tool/selfcheck/units-ask.mjs +85 -0
- package/tool/selfcheck/units-brief.mjs +3 -13
- package/tool/selfcheck/units-cadence.mjs +26 -1
- package/tool/selfcheck/units-context.mjs +2 -1
- package/tool/selfcheck/units-feedback.mjs +137 -0
- package/tool/selfcheck/units-learn.mjs +32 -0
- package/tool/selfcheck/units-level.mjs +21 -1
- package/tool/selfcheck/units-prompt.mjs +106 -0
- package/tool/selfcheck/units-repo.mjs +20 -1
package/tool/commands/doctor.mjs
CHANGED
|
@@ -3,243 +3,18 @@
|
|
|
3
3
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { scopeOutput, splitAdvice, changedFiles } from "../lib/scope.mjs";
|
|
7
6
|
import { CWD, PKG_ROOT, TARGET_DIR, MANIFEST, SELF, c, exists, die, RUNTIME_FILES } from "../lib/core.mjs";
|
|
7
|
+
import { maybeAsk } from "./feedback.mjs";
|
|
8
8
|
import { cmdProbe, probeStatus } from "./probe.mjs";
|
|
9
|
-
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS,
|
|
9
|
+
import { readManifest, assessLevel, unknownKeys, KNOWN_KEYS, layoutChecks, unparsedLines } from "../lib/manifest.mjs";
|
|
10
10
|
import { proveGates } from "../lib/prove.mjs";
|
|
11
|
-
import { detectFacts,
|
|
12
|
-
import {
|
|
13
|
-
import { proposeGates, readAdoptFiles } from "../lib/adopt.mjs";
|
|
14
|
-
import { assessBaseline, DEP_FILES, BASELINE_TOTAL } from "../lib/baseline.mjs";
|
|
11
|
+
import { detectFacts, claudeShimFor } from "../lib/repo.mjs";
|
|
12
|
+
import { reportBaseline, reportCatalog } from "./doctor-catalog.mjs";
|
|
15
13
|
import { L } from "../i18n/index.mjs";
|
|
16
14
|
import { countArbiters } from "./context.mjs";
|
|
17
15
|
import { beginBrief, finishBrief } from "../lib/brief.mjs";
|
|
18
|
-
import { declaredGates, sinceRef, runGates, progress, listArg } from "../lib/run.mjs";
|
|
19
|
-
import { autoProbeAllowed } from "../lib/cadence.mjs";
|
|
20
|
-
|
|
21
|
-
// Обязательный минимум проекта — прогоном, а не по памяти. До сих пор это было единственное
|
|
22
|
-
// место, где комплект просил верить на слово, что человек прочитал методичку и сверился.
|
|
23
|
-
async function reportBaseline(man, facts) {
|
|
24
|
-
const { readdir, readFile } = await import("node:fs/promises");
|
|
25
|
-
let files = [];
|
|
26
|
-
try {
|
|
27
|
-
files = (await readdir(CWD, { withFileTypes: true })).map((d) => d.name);
|
|
28
|
-
} catch { /* пустой список честнее выдуманного: ни один пункт не подтвердится */ }
|
|
29
|
-
|
|
30
|
-
// Файлы зависимостей читаются целиком и склеиваются: трекер ошибок объявляют по-разному в
|
|
31
|
-
// каждой экосистеме, а искать его надо одинаково.
|
|
32
|
-
let depsText = "";
|
|
33
|
-
for (const f of DEP_FILES) {
|
|
34
|
-
if (!files.some((n) => n.toLowerCase() === f)) continue;
|
|
35
|
-
try { depsText += (await readFile(join(CWD, f), "utf8")).toLowerCase() + "\n"; } catch { /* нечитаемый файл — просто не признак */ }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const rows = assessBaseline({ files, gateKeys: facts.gateKeys, facts, manifest: man || {}, depsText });
|
|
39
|
-
const okCount = rows.filter((r) => r.ok).length;
|
|
40
|
-
|
|
41
|
-
console.log(c.bold(`\n ${L.baseline.heading}\n`));
|
|
42
|
-
console.log(c.dim(` ${L.baseline.intro(rows.length, BASELINE_TOTAL)}`));
|
|
43
|
-
console.log(c.dim(` ${L.baseline.caveat}\n`));
|
|
44
|
-
for (const r of rows) {
|
|
45
|
-
const mark = r.ok ? c.green("✔") : c.yellow("✘");
|
|
46
|
-
const title = L.baseline.titles[r.key] || r.key;
|
|
47
|
-
console.log(` ${mark} ${String(r.n).padStart(2)}. ${title}`);
|
|
48
|
-
console.log(c.dim(` ${r.ok ? L.baseline.by(r.by) : L.baseline.none}`));
|
|
49
|
-
}
|
|
50
|
-
console.log(
|
|
51
|
-
"\n " + (okCount === rows.length ? c.green(`${okCount}/${rows.length}`) : c.yellow(`${okCount}/${rows.length}`)) +
|
|
52
|
-
c.dim(` · ${L.baseline.eyes(BASELINE_TOTAL - rows.length, "kit/docs/ai/project-baseline.md")}\n`)
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function reportCatalog(man, facts, probe = null) {
|
|
57
|
-
const catalog = await readCatalog();
|
|
58
|
-
if (!catalog.length) return;
|
|
59
|
-
|
|
60
|
-
// Четвёртая корзина, а не третья: «закрыто другим арбитром» — это НЕ «не поставлено».
|
|
61
|
-
// Пока их считали вместе, вывод каждый прогон называл долгом то, что уже держит biome или
|
|
62
|
-
// ruff. Просьба первого чужого пользователя; она же — наша собственная норма про вывод.
|
|
63
|
-
const { covered, unknownGates } = coversOf(man);
|
|
64
|
-
const { held, todo, skip, byOther } = catalogBuckets(catalog, facts, covered);
|
|
65
|
-
|
|
66
|
-
console.log(c.bold(`\n ${L.doctor.gatesHeading}\n`));
|
|
67
|
-
const marks = ["has_ci", "has_db", "has_docker", "has_tests", "has_deps"]
|
|
68
|
-
.filter((k) => facts[k])
|
|
69
|
-
.map((k) => k.replace("has_", ""));
|
|
70
|
-
console.log(
|
|
71
|
-
c.dim(` ${L.doctor.langs}: ${[...facts.langs].join(", ") || L.doctor.langsUnknown} · ${L.doctor.files}: ${facts.files}` +
|
|
72
|
-
(marks.length ? ` · ${L.doctor.hasThings}: ${marks.join(", ")}` : "") + "\n")
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
for (const rec of held) console.log(` ${c.green("✔")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
|
|
76
|
-
// ЧТО У ВАС УЖЕ ЕСТЬ — до итога и до списка крестов. Комплект, поставленный в проект с
|
|
77
|
-
// eslint, mocha и конвейером, показывал двадцать крестов и «держит машина 0»: мы считали
|
|
78
|
-
// только СВОИ записи, а чужие проверки не читали вовсе. С точки зрения владельца это
|
|
79
|
-
// неправда, и первое, что он видел, было обвинением. Предлагаем, а не вписываем: гейт в
|
|
80
|
-
// чужом манифесте без спроса — наше решение в чужом файле.
|
|
81
|
-
if (!declaredGates(man).length) {
|
|
82
|
-
const found = proposeGates(await readAdoptFiles(CWD));
|
|
83
|
-
if (found.length) {
|
|
84
|
-
console.log(`\n ${c.bold(L.doctor.haveAlready(found.length))}`);
|
|
85
|
-
for (const g of found) {
|
|
86
|
-
console.log(` ${c.green("✔")} ${g.name.padEnd(12)} ${c.dim(`${g.cmd} ← ${g.source}`)}`);
|
|
87
|
-
}
|
|
88
|
-
console.log(c.dim(` ${L.doctor.haveAlreadyHow(found.map((g) => `${g.name}: "${g.cmd}"`).join(" "))}`));
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ЧТО ВАШИ ПРОВЕРКИ ПРОПУСТИЛИ. Проба знала имена непойманных классов и писала в отметку одно
|
|
93
|
-
// число; человек в `doctor` не видел ничего. Это самое конкретное, что мы знаем о проекте, —
|
|
94
|
-
// не «хорошая практика», а брак, подсаженный в ЕГО файл и ЕГО проверками не замеченный, —
|
|
95
|
-
// поэтому стоит выше списка «с чего начать». Читается из файла: ничего не запускает.
|
|
96
|
-
const blindOnes = (probe?.classes || []).map((b) => [b, catalog.find((r) => r.slug === b.slug)]).filter(([, r]) => r);
|
|
97
|
-
if (blindOnes.length) {
|
|
98
|
-
console.log(`\n ${c.yellow("⚠")} ${c.bold(L.doctor.blindHeading(probe.behind))}`);
|
|
99
|
-
for (const [b, rec] of blindOnes) {
|
|
100
|
-
// Три случая, и сливать их нельзя. Гейт стоял и проба его ГОНЯЛА — «стоит, но здесь не
|
|
101
|
-
// ловит», самое ценное. Гейт объявлен, но проба его не гоняла (поставлен позже или
|
|
102
|
-
// медленный) — «поймает ли, покажет следующая», а не «пойман». Гейта нет — совет.
|
|
103
|
-
const ranIt = probe.ran?.has(rec.slug);
|
|
104
|
-
const now = facts.gateKeys.includes(rec.slug);
|
|
105
|
-
console.log(` ${now && !ranIt ? c.dim("~") : c.red("✘")} ${rec.slug.padEnd(22)} ${c.dim(`${rec.intent || ""} ← ${b.file}`)}`);
|
|
106
|
-
if (ranIt) { console.log(c.dim(` ${L.doctor.blindRan(rec.slug)}`)); continue; }
|
|
107
|
-
if (now) { console.log(c.dim(` ${L.doctor.blindInstalled}`)); continue; }
|
|
108
|
-
const adv = blindAdvice(rec, facts, {});
|
|
109
|
-
if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
|
|
110
|
-
else console.log(c.dim(` ${L.doctor.install(`${SELF} add ${rec.slug}`)}`));
|
|
111
|
-
}
|
|
112
|
-
console.log(c.dim(` ${L.doctor.blindMore(`${SELF} probe`)}`));
|
|
113
|
-
} else if (probe?.state === "never" && declaredGates(man).length) {
|
|
114
|
-
console.log(c.dim(`\n ${L.doctor.probeNever(`${SELF} probe`)}`));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// С ЧЕГО НАЧАТЬ. Двадцать одинаковых крестов — это ноль требований: закрывают первое
|
|
118
|
-
// попавшееся или не закрывают ничего. Порядок не по нашему вкусу: сперва то, что родилось из
|
|
119
|
-
// настоящего отказа И закрывается одной готовой командой.
|
|
120
|
-
const first = todo.length > 3 ? startWith(todo, facts, 3) : [];
|
|
121
|
-
if (first.length) {
|
|
122
|
-
console.log(`\n ${c.bold(L.doctor.startWith)}`);
|
|
123
|
-
for (const rec of first) {
|
|
124
|
-
const adv = blindAdvice(rec, facts, {});
|
|
125
|
-
console.log(` ${c.yellow("→")} ${rec.slug.padEnd(22)} ${c.dim(rec.intent || "")}`);
|
|
126
|
-
if (adv.command) console.log(c.dim(` ${L.doctor.startCmd(adv.command)}`));
|
|
127
|
-
if (adv.tool) console.log(c.dim(` ${L.doctor.startTool(adv.tool)}`));
|
|
128
|
-
}
|
|
129
|
-
// Одна проверка руками — это разовый героизм. Сказать про хук здесь, а не в конце: человек
|
|
130
|
-
// читает первые строки и закрывает, а именно сейчас у него в руках список того, что стоит
|
|
131
|
-
// повесить перед пушем.
|
|
132
|
-
console.log(c.dim(`\n ${L.doctor.startHook}`));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ОСТАЛЬНОЕ — ПОСЛЕ ГЛАВНОГО И СЖАТО. Список шёл первым, по две строки на запись (вторая —
|
|
136
|
-
// «поставить: aqk add …»), и на requests главное начиналось со строки 84 из 102: человек
|
|
137
|
-
// читает сверху и закрывает раньше. Разбор соседа 2026-09-11 (research/competitors/agentlint.md):
|
|
138
|
-
// там первыми идут пять главных исправлений. Записи не теряются — теряется повтор подсказки.
|
|
139
|
-
const rest = todo.filter((r) => !first.includes(r));
|
|
140
|
-
if (rest.length) {
|
|
141
|
-
if (first.length) console.log(`\n ${c.bold(L.doctor.todoRest(rest.length))}`);
|
|
142
|
-
else console.log("");
|
|
143
|
-
// ○, а не ✘: запись не установлена — это не падение. Крест в зелёном прогоне глаз читает
|
|
144
|
-
// как провал, и через неделю человек перестаёт смотреть на красное вообще (отзыв с живого
|
|
145
|
-
// проекта 2026-09-11). ✘ остаётся за тем, что упало или пропустило брак.
|
|
146
|
-
for (const rec of rest) console.log(` ${c.dim("○")} ${rec.slug.padEnd(22)} ${rec.intent || ""}`);
|
|
147
|
-
console.log(c.dim(` ${L.doctor.todoRestHow(SELF)}`));
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Второстепенное — в конце: что закрыто чужим арбитром, что неприменимо, советы без вердикта.
|
|
151
|
-
if (byOther.length) {
|
|
152
|
-
console.log(c.dim(`\n ${L.doctor.coveredBy(byOther.length)}`));
|
|
153
|
-
for (const [rec, gate] of byOther) console.log(c.dim(` ~ ${rec.slug.padEnd(22)} ${L.doctor.coveredByGate(gate)}`));
|
|
154
|
-
}
|
|
155
|
-
// Гейт, которого нет в gates:, не закрывает ничего — и молчать об этом нельзя: человек
|
|
156
|
-
// считает запись закрытой, а её не держит никто. Называется поимённо, жёлтым.
|
|
157
|
-
if (unknownGates.length) {
|
|
158
|
-
console.log(c.yellow(`\n ${L.doctor.coversUnknown(unknownGates.join(", "))}`));
|
|
159
|
-
}
|
|
160
|
-
// Заявка «эту запись держит наш линтер» сверяется с кодами правил из рецепта записи.
|
|
161
|
-
// Замерено на живом ruff.toml: девятнадцать групп правил, а print() не ловится — и заявка
|
|
162
|
-
// сняла бы запись с долга, не закрыв её ничем.
|
|
163
|
-
// Конфиги — ПО ЛИНТЕРАМ, а не одной склейкой: заявка сверяется правилами того линтера,
|
|
164
|
-
// которым закрыт гейт (отзыв с живого проекта 2026-09-11 — коды ruff искались в biome.json).
|
|
165
|
-
const readAll = async (names) => {
|
|
166
|
-
let t = "";
|
|
167
|
-
for (const f of names) { try { t += await readFile(join(CWD, f), "utf8") + "\n"; } catch { /* нет файла */ } }
|
|
168
|
-
return t;
|
|
169
|
-
};
|
|
170
|
-
let scripts = {}, pkgText = "";
|
|
171
|
-
try { pkgText = await readFile(join(CWD, "package.json"), "utf8"); scripts = JSON.parse(pkgText)?.scripts || {}; } catch { /* нет или не JSON */ }
|
|
172
|
-
const configs = {
|
|
173
|
-
// ruff.toml и .ruff.toml — конфиг ruff целиком, слово «ruff» в них писать незачем (поймал наш же
|
|
174
|
-
// smoke: `extend-select = [..., "T20"]` выбрасывался). pyproject.toml — только если в нём есть
|
|
175
|
-
// раздел ruff: он есть почти у каждого python-проекта и без ruff.
|
|
176
|
-
ruff: (await readAll(["ruff.toml", ".ruff.toml"])) +
|
|
177
|
-
((await readAll(["pyproject.toml"])).match(/^\[tool\.ruff[\s\S]*/m)?.[0] || ""),
|
|
178
|
-
eslint: (await readAll([".eslintrc", ".eslintrc.json", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"])) +
|
|
179
|
-
(/"eslintConfig"/.test(pkgText) ? pkgText : ""),
|
|
180
|
-
biome: await readAll(["biome.json", "biome.jsonc"]),
|
|
181
|
-
scripts,
|
|
182
|
-
};
|
|
183
|
-
for (const u of coversUnproven(man, catalog, configs)) {
|
|
184
|
-
if (u.kind === "unproven") {
|
|
185
|
-
console.log(c.yellow(`\n ${L.doctor.coversUnproven(u.entry, u.gate, u.codes.join(", "))}`));
|
|
186
|
-
console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
|
|
187
|
-
} else if (u.kind === "impossible") {
|
|
188
|
-
console.log(c.yellow(`\n ${L.doctor.coversImpossible(u.entry, u.gate, u.linter)}`));
|
|
189
|
-
console.log(c.dim(` ${L.doctor.coversUnprovenHow(`${SELF} add ${u.entry}`)}`));
|
|
190
|
-
} else {
|
|
191
|
-
console.log(c.dim(`\n ${L.doctor.coversCantCheck(u.entry, u.gate)}`));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// Не вердикт, а совет: отсутствие браузерного сервера — незанятая возможность, а не дефект.
|
|
195
|
-
// Поэтому строка тусклая и без значка, и её нет у проекта без интерфейса.
|
|
196
|
-
let mcpText = "";
|
|
197
|
-
for (const f of [".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json", ".claude/mcp.json"]) {
|
|
198
|
-
try { mcpText += await readFile(join(CWD, f), "utf8"); } catch { /* нет файла — нечего читать */ }
|
|
199
|
-
}
|
|
200
|
-
const browser = browserServerAdvice(facts, mcpText);
|
|
201
|
-
if (browser) {
|
|
202
|
-
console.log(c.dim(`\n ${L.doctor.noBrowserServer}`));
|
|
203
|
-
console.log(c.dim(` ${L.doctor.noBrowserServerHow(browser.servers.join(" · "))}`));
|
|
204
|
-
}
|
|
205
|
-
if (skip.length) {
|
|
206
|
-
console.log(c.dim(`\n ${L.doctor.notApplicable(skip.length)}`));
|
|
207
|
-
for (const [rec, why] of skip) console.log(c.dim(` · ${rec.slug.padEnd(22)} ${why}`));
|
|
208
|
-
}
|
|
209
|
-
console.log(
|
|
210
|
-
`\n ${c.bold(L.doctor.total)} ${L.doctor.totalHeld(held.length)}, ${L.doctor.totalTodo(c.yellow(todo.length))}, ` +
|
|
211
|
-
(byOther.length ? `${L.doctor.totalCovered(byOther.length)}, ` : "") +
|
|
212
|
-
c.dim(L.doctor.totalSkip(skip.length)) + "\n"
|
|
213
|
-
);
|
|
214
|
-
// Числа отдаются наружу, а не пересчитываются второй раз: два счёта одного и того же
|
|
215
|
-
// расходятся ровно так же, как два списка команд.
|
|
216
|
-
return { held: held.length, todo: todo.length, todoRecs: todo };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Короткий отчёт «что из этого реально брали» — не для человека, а для агента в следующей
|
|
220
|
-
// сессии и для самого владельца: список объявленных гейтов молчит о том, сколько из них
|
|
221
|
-
// действительно стоят и работают именно СЕЙЧАС. Перезаписывается каждым прогоном, не копится:
|
|
222
|
-
// история — дело git-лога коммитов с этим отчётом, если владелец решит его коммитить.
|
|
223
|
-
async function writeRunReport({ version, reached, results, skipped = [] }) {
|
|
224
|
-
const stamp = new Date().toISOString().replace("T", " ").slice(0, 16);
|
|
225
|
-
const ok = results.filter((r) => r.ok).length;
|
|
226
|
-
const lines = [
|
|
227
|
-
`# ${L.report.title} — ${stamp}`,
|
|
228
|
-
version ? `${L.report.version}: ${version}` : null,
|
|
229
|
-
`${L.report.level}: AQK-${reached < 0 ? L.doctor.levelNone : reached}`,
|
|
230
|
-
"",
|
|
231
|
-
...results.map((r) => `${r.ok ? "✔" : "✘"} ${r.name} — ${r.secs}s${r.ok ? "" : ` (${r.note || L.doctor.exitCode(r.code)})`}`),
|
|
232
|
-
// Пропущенные по --skip/--only — строкой «~»: блок для агента читает их как «не запускались»,
|
|
233
|
-
// а не как зелёные. Молчание о них прочиталось бы как «проверено».
|
|
234
|
-
...skipped.map((n) => `~ ${n} — ${L.report.skippedBySelect}`),
|
|
235
|
-
"",
|
|
236
|
-
L.report.summary(ok, results.length),
|
|
237
|
-
].filter((l) => l !== null);
|
|
238
|
-
|
|
239
|
-
const dst = join(CWD, TARGET_DIR, "last-run.md");
|
|
240
|
-
await mkdir(join(CWD, TARGET_DIR), { recursive: true });
|
|
241
|
-
await writeFile(dst, lines.join("\n") + "\n", "utf8");
|
|
242
|
-
}
|
|
16
|
+
import { declaredGates, sinceRef, runGates, progress, listArg, writeRunReport } from "../lib/run.mjs";
|
|
17
|
+
import { autoProbeAllowed, levelLimits } from "../lib/cadence.mjs";
|
|
243
18
|
|
|
244
19
|
// ПРОБА ЗАПУСКАЕТСЯ САМА, раз в сто коммитов, — кроме конвейера (там это минуты сюрпризом в
|
|
245
20
|
// быстрой проверке, отзыв с живого проекта 2026-09-11). Не влияет на код возврата никогда: это
|
|
@@ -259,6 +34,12 @@ async function autoProbe(brief) {
|
|
|
259
34
|
|
|
260
35
|
async function cmdDoctor() {
|
|
261
36
|
const brief = process.argv.includes("--brief");
|
|
37
|
+
// Коротко по умолчанию, поимённо по `--verbose`. Отзыв с живого проекта 2026-09-11: вывод на
|
|
38
|
+
// сто строк, из них семьдесят — зелёные галочки, и красное теряется между ними. Сворачивается
|
|
39
|
+
// только то, что ничего не требует: пройденное, неприменимое, пояснения. Упавшее, совет и
|
|
40
|
+
// «что поставить» печатаются всегда.
|
|
41
|
+
// AQK_VERBOSE=1 — то же для конвейера, где лог читают потом и целиком.
|
|
42
|
+
const verbose = process.argv.includes("--verbose") || process.env.AQK_VERBOSE === "1";
|
|
262
43
|
const buf = brief ? beginBrief() : null;
|
|
263
44
|
// Версия в шапке — единственное, что привязывает баг-репорт к коммиту, если ставили не из
|
|
264
45
|
// релиза: без неё "у меня не работает" ничем не отличается от любой другой версии за год.
|
|
@@ -301,6 +82,11 @@ async function cmdDoctor() {
|
|
|
301
82
|
}
|
|
302
83
|
}
|
|
303
84
|
|
|
85
|
+
// СВОД, КОТОРОГО НЕ ВИДИТ CLAUDE CODE. Он читает CLAUDE.md, а не AGENTS.md (документация,
|
|
86
|
+
// сверено 2026-09-11); подробности и исходы — claudeSeesRules.
|
|
87
|
+
const shim = await claudeShimFor(CWD);
|
|
88
|
+
if (shim) console.log(`\n ${c.yellow("!")} ${L.doctor.claudeShim[shim]}`);
|
|
89
|
+
|
|
304
90
|
// Команды в точке входа заполнены или остались пустыми заготовками? Файл берётся тот же,
|
|
305
91
|
// что проверен выше, — иначе проект на `CLAUDE.md` этой проверки не получал вовсе.
|
|
306
92
|
const entryFile = (Array.isArray(man?.entry) ? man.entry : []).find((e) => typeof e === "string" && e.trim())?.trim() || "AGENTS.md";
|
|
@@ -319,7 +105,7 @@ async function cmdDoctor() {
|
|
|
319
105
|
const arb = countArbiters(text, ["человек", "human", "nobody"]);
|
|
320
106
|
if (arb.total && arb.human) {
|
|
321
107
|
console.log(`\n ${c.yellow("!")} ${L.doctor.rulesByHuman(arb.total, arb.machine, arb.human)}`);
|
|
322
|
-
console.log(c.dim(` ${L.doctor.rulesByHumanWhy}`));
|
|
108
|
+
if (verbose) console.log(c.dim(` ${L.doctor.rulesByHumanWhy}`));
|
|
323
109
|
}
|
|
324
110
|
|
|
325
111
|
const emptyCommands = (text.match(/^- [^:]+: ``$/gm) || []).length;
|
|
@@ -390,6 +176,16 @@ async function cmdDoctor() {
|
|
|
390
176
|
} else {
|
|
391
177
|
console.log(c.green(` ${L.doctor.allDone}\n`));
|
|
392
178
|
}
|
|
179
|
+
// Состояние пробы — из файла отметки, миллисекунды. Нет его — блок про пробу просто молчит.
|
|
180
|
+
let probe = null;
|
|
181
|
+
try { probe = await probeStatus(); } catch { /* пробы нет — и ладно */ }
|
|
182
|
+
// Чего уровень НЕ доказывает — сразу под ним, пока глаз на нём (см. levelLimits).
|
|
183
|
+
if (reached >= 1) {
|
|
184
|
+
const lim = levelLimits(probe);
|
|
185
|
+
console.log(c.dim(` ${L.doctor.limitsTitle}`));
|
|
186
|
+
console.log(` ${L.doctor.limitsProbe[lim.kind](lim, `${SELF} probe`)}`);
|
|
187
|
+
console.log(` ${L.doctor.limitsCi}\n`);
|
|
188
|
+
}
|
|
393
189
|
|
|
394
190
|
const facts = await detectFacts(man);
|
|
395
191
|
if (process.argv.includes("--baseline")) {
|
|
@@ -402,16 +198,14 @@ async function cmdDoctor() {
|
|
|
402
198
|
await reportBaseline(man, facts);
|
|
403
199
|
process.exit(0);
|
|
404
200
|
}
|
|
405
|
-
|
|
406
|
-
let probe = null;
|
|
407
|
-
try { probe = await probeStatus(); } catch { /* пробы нет — и ладно */ }
|
|
408
|
-
const cat = (await reportCatalog(man, facts, probe)) || { held: 0, todo: 0, todoRecs: [] };
|
|
201
|
+
const cat = (await reportCatalog(man, facts, probe, verbose)) || { held: 0, todo: 0, todoRecs: [] };
|
|
409
202
|
|
|
410
203
|
// «Объявлен» ≠ «работает». Без --run говорим это вслух, а не молчим.
|
|
411
204
|
const wantRun = process.argv.includes("--run");
|
|
412
205
|
const gates = declaredGates(man);
|
|
413
206
|
let gateFailed = 0;
|
|
414
207
|
let failedNames = [];
|
|
208
|
+
let cannotNames = [];
|
|
415
209
|
let skippedNames = [];
|
|
416
210
|
if (wantRun) {
|
|
417
211
|
// --jobs N: сколько гейтов одновременно. Без флага — по одному, как было: чужие гейты бывают
|
|
@@ -420,7 +214,7 @@ async function cmdDoctor() {
|
|
|
420
214
|
const ji = process.argv.indexOf("--jobs");
|
|
421
215
|
const jobs = ji > -1 ? Number(process.argv[ji + 1]) : 1;
|
|
422
216
|
if (!Number.isInteger(jobs) || jobs < 1) die(L.doctor.jobsBad(process.argv[ji + 1] ?? ""));
|
|
423
|
-
const run = await runGates(man, { since: sinceRef(), only: listArg(process.argv, "--only"), skip: listArg(process.argv, "--skip"), jobs });
|
|
217
|
+
const run = await runGates(man, { since: sinceRef(), only: listArg(process.argv, "--only"), skip: listArg(process.argv, "--skip"), jobs, verbose });
|
|
424
218
|
gateFailed = run.failed;
|
|
425
219
|
failedNames = run.results.filter((r) => !r.ok).map((r) => r.name);
|
|
426
220
|
skippedNames = run.skipped || [];
|
|
@@ -437,6 +231,7 @@ async function cmdDoctor() {
|
|
|
437
231
|
// стоят дороже. Не влияет на код возврата НИКОГДА — это осмотр, а не порог.
|
|
438
232
|
// Выключается AQK_PROBE=0 — у всего, что случается само, обязан быть выключатель.
|
|
439
233
|
if (!brief && process.env.AQK_PROBE !== "0") await autoProbe(brief);
|
|
234
|
+
cannotNames = run.results.filter((r) => r.cannot).map((r) => r.name);
|
|
440
235
|
} else if (gates.length) {
|
|
441
236
|
console.log(
|
|
442
237
|
c.yellow(` ${L.doctor.declaredNotRun(gates.length)}`) +
|
|
@@ -444,6 +239,20 @@ async function cmdDoctor() {
|
|
|
444
239
|
);
|
|
445
240
|
}
|
|
446
241
|
|
|
242
|
+
// ЕДИНСТВЕННАЯ ПЛАТА ЗА КОМПЛЕКТ — один ответ автору. Человеку говорим здесь, агенту — в
|
|
243
|
+
// блоке `context`; текст и решение «есть ли о чём просить» одни на оба места (feedback.mjs),
|
|
244
|
+
// отметка одна на проект (ask.mjs): кто первым дошёл, тот и спросил, второй раз не спрашивает
|
|
245
|
+
// никто. В кратком режиме молчим — там ворота коммита, и лишняя строка там дороже всего.
|
|
246
|
+
const askText = brief ? null : await maybeAsk({
|
|
247
|
+
cannot: cannotNames,
|
|
248
|
+
red: failedNames.filter((n) => !cannotNames.includes(n)),
|
|
249
|
+
blind: (probe?.classes || []).map((b) => b.slug),
|
|
250
|
+
}, SELF);
|
|
251
|
+
if (askText) {
|
|
252
|
+
for (const l of askText.split("\n")) console.log(c.dim(` ${l}`));
|
|
253
|
+
console.log("");
|
|
254
|
+
}
|
|
255
|
+
|
|
447
256
|
// Код возврата — для конвейера. Порог задаётся так: aqk doctor --min 1
|
|
448
257
|
const minIdx = process.argv.indexOf("--min");
|
|
449
258
|
const min = minIdx > -1 ? Number(process.argv[minIdx + 1]) : null;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tool/commands/feedback.mjs — `aqk feedback`: единственная плата за комплект — один ответ.
|
|
2
|
+
//
|
|
3
|
+
// ЗАЧЕМ. Замер 2026-09-14: 1342 скачивания в неделю в npm и ни одного пользователя — версии
|
|
4
|
+
// качаются равномерно, включая прожившую двадцать пять минут, то есть это зеркала и сканеры. На
|
|
5
|
+
// GitHub за две недели семь уникальных посетителей, две звезды, ноль чужих комментариев за всё
|
|
6
|
+
// время. Обратной связи нет не потому, что люди молчат: просить мы не умеем. Единственная
|
|
7
|
+
// просьба печаталась при `init` — ДО того, как комплект сделал хоть что-то полезное, — и звала
|
|
8
|
+
// поставить звезду, то есть просила у человека, которому ещё ничего не дали.
|
|
9
|
+
//
|
|
10
|
+
// ЧТО ЗДЕСЬ ДРУГОЕ. Просим, только когда есть что рассказать, и рассказ уже собран: версия,
|
|
11
|
+
// уровень, стек, что покраснело, чего комплект НЕ СМОГ проверить, какие классы брака не ловит
|
|
12
|
+
// никто. Человеку остаётся одна строка своими словами.
|
|
13
|
+
//
|
|
14
|
+
// ЧЕГО ЗДЕСЬ НЕТ И НЕ БУДЕТ. Ничего не отправляется само. Исходящий запрос у комплекта ровно
|
|
15
|
+
// один — про свежесть версии, он описан в README и SECURITY.md. Отчёт печатается и отдаётся
|
|
16
|
+
// человеку: он видит глазами всё, что отправляет. Ни путей, ни содержимого файлов, ни имени
|
|
17
|
+
// репозитория в отчёте нет — иначе первый же внимательный читатель назовёт это телеметрией,
|
|
18
|
+
// и будет прав.
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { PKG_ROOT, SELF, REPO_URL, c, stateDirs } from "../lib/core.mjs";
|
|
21
|
+
import { askAllowed, markAsked } from "../lib/ask.mjs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { readManifest, assessLevel } from "../lib/manifest.mjs";
|
|
24
|
+
import { detectFacts } from "../lib/repo.mjs";
|
|
25
|
+
import { declaredGates, readRun } from "../lib/run.mjs";
|
|
26
|
+
import { probeStatus } from "./probe.mjs";
|
|
27
|
+
import { L } from "../i18n/index.mjs";
|
|
28
|
+
|
|
29
|
+
// О ЧЁМ ПРОСИТЬ — чистая функция от состояния. Порядок не по нашему удобству, а по ценности
|
|
30
|
+
// ответа для того, кто чинит комплект:
|
|
31
|
+
// 1. НЕ СМОГЛИ ПРОВЕРИТЬ — отказ самого прибора. Это жалоба, а жалоба даётся людям легче
|
|
32
|
+
// похвалы, и она же показывает, где инструмент врёт. Дороже всего остального.
|
|
33
|
+
// 2. СЛЕПОЙ КЛАСС — проба подсадила брак, и его не поймал никто. Рассказ об этом проверяет
|
|
34
|
+
// главное наше утверждение: что проба находит настоящие дыры, а не выдуманные.
|
|
35
|
+
// 3. КРАСНЫЙ ГЕЙТ — комплект поймал то, ради чего его ставят. Момент пользы, но самый частый,
|
|
36
|
+
// поэтому последний.
|
|
37
|
+
// Ничего из перечисленного нет — просьбы нет вовсе. «Оставьте отзыв» без содержания это шум,
|
|
38
|
+
// а шум выключают вместе с хуком, в котором он приехал.
|
|
39
|
+
function feedbackAsk(state = {}) {
|
|
40
|
+
const pick = (kind, list) => (list && list.length ? { reason: kind, names: [...list] } : null);
|
|
41
|
+
return pick("cannot", state.cannot) || pick("blind", state.blind) || pick("red", state.red) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ОТЧЁТ. Каждая строка — либо факт, либо слово «неизвестно»: пустое место в письме читается как
|
|
45
|
+
// «всё хорошо» ровно так же, как пустой вывод проверки, и это тот же порок, только у нас самих.
|
|
46
|
+
//
|
|
47
|
+
// Слепые классы называются именем класса, без файла. Файл знает проба («blind-class: slug path»), и
|
|
48
|
+
// соблазн положить его сюда велик — он объясняет находку. Нельзя: путь внутри чужого
|
|
49
|
+
// репозитория рассказывает о чужом проекте больше, чем его владелец собирался рассказать.
|
|
50
|
+
function reportText(state = {}, T = L.feedback.report) {
|
|
51
|
+
const u = T.unknown;
|
|
52
|
+
const list = (xs) => (xs && xs.length ? xs.join(", ") : T.none);
|
|
53
|
+
return [
|
|
54
|
+
T.title,
|
|
55
|
+
"",
|
|
56
|
+
T.env(state.version || u, state.node || u, state.platform || u),
|
|
57
|
+
T.level(state.level === null || state.level === undefined ? u : `AQK-${state.level}`),
|
|
58
|
+
T.stack(state.langs && state.langs.length ? state.langs.join(", ") : u),
|
|
59
|
+
T.gates(state.gates === undefined ? u : state.gates, list(state.red), list(state.cannot)),
|
|
60
|
+
// `null` — проба не делалась: это НЕИЗВЕСТНО, а не «нет». Пустой список означал бы, что
|
|
61
|
+
// проба прошла и ничего не нашла, — и автор письма прочитал бы чужое незнание как чистоту.
|
|
62
|
+
T.blind(state.blind === null || state.blind === undefined
|
|
63
|
+
? u
|
|
64
|
+
: list(state.blind.map((b) => (typeof b === "string" ? b : b.slug)))),
|
|
65
|
+
"",
|
|
66
|
+
T.say,
|
|
67
|
+
"",
|
|
68
|
+
T.mark(state.version || u),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// КАК ЭТО ЗВУЧИТ — один текст на два места: `context` кладёт его агенту, `doctor` печатает
|
|
73
|
+
// человеку. Собери строку в каждом месте отдельно — и через месяц они разойдутся, как разошлись
|
|
74
|
+
// бы `context` и `prompt` без общего `readAdvice`.
|
|
75
|
+
//
|
|
76
|
+
// АГЕНТУ — ОТДЕЛЬНАЯ ОГОВОРКА, и она не вежливость. Строка ложится в контекст ЧУЖОГО агента, а
|
|
77
|
+
// он исполняет написанное: без «скажи один раз и не настаивай» это реклама в чужом окне, и хук,
|
|
78
|
+
// которым она приехала, снесут в первый же день вместе со всей затеей. Человеку вместо неё —
|
|
79
|
+
// как выключить: у всего, что случается само, обязан быть выключатель.
|
|
80
|
+
function askLine(ask, self = "aqk", { agent = false } = {}, T = L.feedback.ask) {
|
|
81
|
+
if (!ask || !ask.reason) return null;
|
|
82
|
+
const say = T[ask.reason];
|
|
83
|
+
if (!say) return null;
|
|
84
|
+
// Перенос, а не пробел: команда обязана оставаться ПОСЛЕДНЕЙ в своей строке — иначе
|
|
85
|
+
// следующее слово приклеится к ней при копировании, и человек выполнит не то.
|
|
86
|
+
return `${say(ask.names.join(", "), `${self} feedback`)}\n${agent ? T.agent : T.once}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Выключатель — тот же, что у совета (AQK_ADVICE=0), пробы (AQK_PROBE=0) и проверки версии
|
|
90
|
+
// (AQK_UPDATE=0). Молчаливой просьбы, которую нельзя отменить, у нас не будет.
|
|
91
|
+
function feedbackWanted(env = process.env) {
|
|
92
|
+
return String(env.AQK_FEEDBACK || "") !== "0";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ОДНА ПРОСЬБА НА ПРОЕКТ — и решение, и ограничитель, и отметка здесь. `doctor` и `context`
|
|
96
|
+
// только печатают: разведи это по двум командам, и они разойдутся в условиях, а человек получит
|
|
97
|
+
// просьбу дважды. Кто первым дошёл, тот и спросил.
|
|
98
|
+
//
|
|
99
|
+
// Ничего не роняет: просьба об одолжении не имеет права стоить человеку прогона.
|
|
100
|
+
async function maybeAsk(state, self, { agent = false } = {}) {
|
|
101
|
+
if (!feedbackWanted()) return null;
|
|
102
|
+
try {
|
|
103
|
+
const dirs = stateDirs();
|
|
104
|
+
if (!(await askAllowed("value", dirs))) return null;
|
|
105
|
+
const line = askLine(feedbackAsk(state), self, { agent });
|
|
106
|
+
if (line) await markAsked("value", dirs);
|
|
107
|
+
return line;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Предзаполненная ссылка. Параметры `title` и `body` — документация GitHub («Creating an issue
|
|
114
|
+
// from a URL query», сверено 2026-09-14). Кодируется ВСЁ: в теле переносы строк, решётки и
|
|
115
|
+
// пробелы, и незакодированная ссылка обрывается на первом же из них — а всё после решётки
|
|
116
|
+
// браузер считает якорем и не передаёт вовсе.
|
|
117
|
+
function issueUrl(repo, title, body) {
|
|
118
|
+
const q = `title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
119
|
+
return `${String(repo).replace(/\/+$/, "")}/issues/new?${q}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function cmdFeedback() {
|
|
123
|
+
const T = L.feedback;
|
|
124
|
+
const man = await readManifest();
|
|
125
|
+
let version = "";
|
|
126
|
+
try { version = JSON.parse(await readFile(join(PKG_ROOT, "package.json"), "utf8")).version || ""; } catch { /* версия просто не покажется */ }
|
|
127
|
+
|
|
128
|
+
const run = await readRun();
|
|
129
|
+
let facts = null;
|
|
130
|
+
try { facts = await detectFacts(man); } catch { /* стек не определили — скажем «неизвестно» */ }
|
|
131
|
+
let level = null;
|
|
132
|
+
if (man?.aqk) { try { level = (await assessLevel(man, null)).reached; } catch { /* уровень не посчитали */ } }
|
|
133
|
+
// Классы известны только когда проба ДЕЙСТВИТЕЛЬНО проходила. «Никогда», «выключена» и «не
|
|
134
|
+
// знаем» — это null, то есть «неизвестно»: см. договор в reportText.
|
|
135
|
+
let blind = null;
|
|
136
|
+
try {
|
|
137
|
+
const st = await probeStatus();
|
|
138
|
+
if (st.state === "fresh" || st.state === "stale") blind = st.classes || [];
|
|
139
|
+
} catch { /* пробы не было — так и скажем */ }
|
|
140
|
+
|
|
141
|
+
const lines = reportText({
|
|
142
|
+
version, node: process.version, platform: process.platform, level,
|
|
143
|
+
langs: facts?.langs ? [...facts.langs] : [],
|
|
144
|
+
gates: declaredGates(man).length,
|
|
145
|
+
red: run?.red || [], cannot: run?.cannot || [], blind,
|
|
146
|
+
});
|
|
147
|
+
const body = lines.join("\n");
|
|
148
|
+
console.log(`\n${body}\n`);
|
|
149
|
+
console.log(c.bold(` ${T.how}`));
|
|
150
|
+
console.log(` ${issueUrl(REPO_URL, T.issueTitle, body)}\n`);
|
|
151
|
+
console.log(c.dim(` ${T.nothingSent}`));
|
|
152
|
+
console.log(c.dim(` ${T.orPaste(`${SELF} feedback`)}\n`));
|
|
153
|
+
// Код возврата всегда 0: команда, которая просит об одолжении и роняет при этом конвейер, —
|
|
154
|
+
// последнее, что человек стерпит.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { cmdFeedback, feedbackAsk, reportText, issueUrl, askLine, feedbackWanted, maybeAsk };
|