agent-quality-kit 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +61 -15
  2. package/README.ru.md +62 -15
  3. package/kit/docs/ai/index.md +1 -0
  4. package/kit/docs/ai/operational-gates.md +275 -0
  5. package/kit/gates/_target.sh +53 -0
  6. package/kit/gates/ci-actually-fails/check.sh +18 -3
  7. package/kit/gates/ci-actually-fails/green/.github/workflows/ci.yml +10 -0
  8. package/kit/gates/entry-commands-exist/check.sh +88 -12
  9. package/kit/gates/hook-actually-fires/README.md +12 -0
  10. package/kit/gates/hook-actually-fires/check.sh +66 -6
  11. package/kit/gates/hook-actually-fires/gate.yml +2 -2
  12. package/kit/gates/hook-actually-fires/green/.claude/hooks/auto-format.sh +3 -0
  13. package/kit/gates/hook-actually-fires/green/.claude/hooks/block-dangerous.sh +3 -0
  14. package/kit/gates/hook-actually-fires/green/.claude/hooks/done.sh +3 -0
  15. package/kit/gates/hook-actually-fires/green/.claude/hooks/idle.sh +3 -0
  16. package/kit/gates/hook-actually-fires/green/.claude/hooks/prompt.sh +3 -0
  17. package/kit/gates/hook-actually-fires/green/.claude/hooks/session.mjs +1 -0
  18. package/kit/gates/hook-actually-fires/green/.claude/hooks/stop-gate.sh +3 -0
  19. package/kit/gates/hook-actually-fires/green/.claude/settings.json +12 -0
  20. package/kit/gates/test-not-adjusted/README.md +31 -0
  21. package/llms.txt +26 -7
  22. package/package.json +1 -1
  23. package/tool/commands/context.mjs +3 -1
  24. package/tool/commands/doctor-catalog.mjs +35 -10
  25. package/tool/commands/feedback.mjs +75 -1
  26. package/tool/commands/gates.mjs +12 -6
  27. package/tool/commands/report.mjs +19 -3
  28. package/tool/commands/vitals.mjs +9 -3
  29. package/tool/i18n/en-docs.mjs +18 -2
  30. package/tool/i18n/en-gates.mjs +9 -1
  31. package/tool/i18n/en.mjs +24 -2
  32. package/tool/i18n/ru-docs.mjs +17 -2
  33. package/tool/i18n/ru-gates.mjs +9 -1
  34. package/tool/i18n/ru.mjs +20 -2
  35. package/tool/lib/adopt.mjs +58 -4
  36. package/tool/lib/core.mjs +42 -6
  37. package/tool/lib/execution.mjs +32 -1
  38. package/tool/lib/manifest.mjs +39 -13
  39. package/tool/lib/prove.mjs +3 -3
  40. package/tool/lib/run.mjs +25 -6
  41. package/tool/selfcheck/smoke/_fixture.mjs +13 -1
  42. package/tool/selfcheck/smoke/feedback-send.test.mjs +87 -0
  43. package/tool/selfcheck/smoke/first-run.test.mjs +67 -3
  44. package/tool/selfcheck/smoke/preflight.test.mjs +83 -0
  45. package/tool/selfcheck/smoke/verdict.test.mjs +50 -4
  46. package/tool/selfcheck/smoke/version-sync.test.mjs +140 -0
  47. package/tool/selfcheck/smoke.sh +106 -4
  48. package/tool/selfcheck/units-execution.mjs +37 -1
  49. package/tool/selfcheck/units-level.mjs +41 -1
  50. package/tool/selfcheck/units-repo.mjs +75 -0
  51. package/tool/selfcheck/units-vitals.mjs +27 -0
  52. package/kit/gates/entry-links-exist/README.md +0 -27
  53. package/kit/gates/entry-links-exist/check.sh +0 -33
  54. package/kit/gates/entry-links-exist/gate.yml +0 -17
  55. package/kit/gates/entry-links-exist/green/AGENTS.md +0 -10
  56. package/kit/gates/entry-links-exist/green/rules/general.md +0 -3
  57. package/kit/gates/entry-links-exist/red/AGENTS.md +0 -3
  58. package/kit/gates/no-phantom-package/README.md +0 -84
  59. package/kit/gates/no-phantom-package/check.sh +0 -168
  60. package/kit/gates/no-phantom-package/gate.yml +0 -20
  61. package/kit/gates/no-phantom-package/green/AGENTS.md +0 -15
  62. package/kit/gates/no-phantom-package/red/AGENTS.md +0 -15
@@ -17,6 +17,7 @@
17
17
  // репозитория в отчёте нет — иначе первый же внимательный читатель назовёт это телеметрией,
18
18
  // и будет прав.
19
19
  import { readFile } from "node:fs/promises";
20
+ import { spawnSync } from "node:child_process";
20
21
  import { PKG_ROOT, SELF, REPO_URL, c, stateDirs } from "../lib/core.mjs";
21
22
  import { askAllowed, markAsked } from "../lib/ask.mjs";
22
23
  import { join } from "node:path";
@@ -65,6 +66,9 @@ function reportText(state = {}, T = L.feedback.report) {
65
66
  "",
66
67
  T.say,
67
68
  "",
69
+ // Слова человека — туда же, где приглашение их написать. Пусто — остаётся приглашение:
70
+ // отправленное письмо без единой своей строки всё равно полезно, но сказать об этом надо.
71
+ ...(state.note ? [state.note, ""] : []),
68
72
  T.mark(state.version || u),
69
73
  ];
70
74
  }
@@ -110,6 +114,65 @@ async function maybeAsk(state, self, { agent = false } = {}) {
110
114
  }
111
115
  }
112
116
 
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // ОТПРАВКА — ТОЛЬКО ПО ЯВНОМУ СЛОВУ, И СЛОВО ЭТО `--send`.
119
+ //
120
+ // Владелец 2026-09-14 спросил, нельзя ли отправлять отзыв «без согласия пользователя, чтобы
121
+ // агент мог быстро сообщить». Нельзя, и не из вежливости: в README и SECURITY.md написано, что
122
+ // исходящий запрос у комплекта ровно один — про версию. Инструмент, который втихую шлёт что-то
123
+ // из ЧУЖОГО репозитория, становится ровно тем, что мы критикуем, а наша аудитория — это те, кто
124
+ // проверяет инструменты на вранье. Одного внимательного читателя хватит.
125
+ //
126
+ // Поэтому согласие живёт в самом флаге: `--send` не набирают случайно, а агенту в умениях
127
+ // сказано показать текст человеку и спросить. Утверждение «без флага не уходит ничего» держит
128
+ // машина — smoke/feedback-send.test.mjs, — а не наше обещание в документации.
129
+ //
130
+ // ОТ ЧЬЕГО ИМЕНИ. От самого человека, его же `gh`. Своего сервера у нас нет и не будет: он
131
+ // означал бы приём чужих данных, а значит и ответственность за них.
132
+ const DISCUSSION = 90;
133
+
134
+ function sendWanted(argv = process.argv) {
135
+ return argv.includes("--send");
136
+ }
137
+
138
+ // Своя строка человека — самое ценное во всём письме. Берём всё, что не флаг.
139
+ function userNote(argv = process.argv) {
140
+ const i = argv.indexOf("feedback");
141
+ return (i === -1 ? [] : argv.slice(i + 1)).filter((a) => !a.startsWith("-")).join(" ").trim();
142
+ }
143
+
144
+ // Чем звать gh. Подменяется `AQK_GH` — тот же приём, что у `AQK_BASH` в execution.mjs: у всего,
145
+ // что мы решаем сами, обязан быть способ решить иначе. Через него же проверки подставляют
146
+ // поддельный gh и убеждаются, что без флага его не зовут вовсе.
147
+ function ghArgs(env = process.env) {
148
+ return String(env.AQK_GH || "gh").split(/\s+/).filter(Boolean);
149
+ }
150
+
151
+ // Запуск БЕЗ оболочки: в теле отзыва переносы строк, кавычки и обратные апострофы, и оболочка
152
+ // разобрала бы их как свои. Доводы уходят массивом — разбирать нечего.
153
+ function gh(args, timeout = 30000) {
154
+ const [bin, ...pre] = ghArgs();
155
+ return spawnSync(bin, [...pre, ...args], { encoding: "utf8", timeout });
156
+ }
157
+
158
+ const Q_ID = `query($o:String!,$n:String!){repository(owner:$o,name:$n){discussion(number:${DISCUSSION}){id}}}`;
159
+ const Q_ADD = "mutation($id:ID!,$body:String!){addDiscussionComment(input:{discussionId:$id,body:$body}){comment{url}}}";
160
+
161
+ // Три исхода, как везде: отправлено · не смогли и сказали почему · входа нет. Молчаливый отказ
162
+ // означал бы, что человек считает отзыв ушедшим, а его нет.
163
+ function postComment(body, repo) {
164
+ if (gh(["auth", "status"], 15000).status !== 0) return { ok: false, reason: "no-auth" };
165
+ const [owner, name] = repo;
166
+ const one = gh(["api", "graphql", "-f", `query=${Q_ID}`, "-f", `o=${owner}`, "-f", `n=${name}`]);
167
+ let id = "";
168
+ try { id = JSON.parse(one.stdout || "{}").data.repository.discussion.id; } catch { /* разберём ниже */ }
169
+ if (!id) return { ok: false, reason: "no-thread" };
170
+ const two = gh(["api", "graphql", "-f", `query=${Q_ADD}`, "-f", `id=${id}`, "-f", `body=${body}`]);
171
+ let url = "";
172
+ try { url = JSON.parse(two.stdout || "{}").data.addDiscussionComment.comment.url; } catch { /* разберём ниже */ }
173
+ return url ? { ok: true, url } : { ok: false, reason: "failed" };
174
+ }
175
+
113
176
  // Предзаполненная ссылка. Параметры `title` и `body` — документация GitHub («Creating an issue
114
177
  // from a URL query», сверено 2026-09-14). Кодируется ВСЁ: в теле переносы строк, решётки и
115
178
  // пробелы, и незакодированная ссылка обрывается на первом же из них — а всё после решётки
@@ -142,13 +205,24 @@ async function cmdFeedback() {
142
205
  version, node: process.version, platform: process.platform, level,
143
206
  langs: facts?.langs ? [...facts.langs] : [],
144
207
  gates: declaredGates(man).length,
145
- red: run?.red || [], cannot: run?.cannot || [], blind,
208
+ red: run?.red || [], cannot: run?.cannot || [], blind, note: userNote(),
146
209
  });
147
210
  const body = lines.join("\n");
148
211
  console.log(`\n${body}\n`);
212
+
213
+ // ОТПРАВКА — ТОЛЬКО ПО ФЛАГУ. Без него ниже печатается ссылка, и это весь путь наружу.
214
+ if (sendWanted()) {
215
+ console.log(c.dim(` ${T.sending}`));
216
+ const repo = REPO_URL.replace(/^https?:\/\/github\.com\//, "").split("/");
217
+ const r = postComment(body, repo);
218
+ if (r.ok) { console.log(c.green(` ${T.sent(r.url)}\n`)); return; }
219
+ console.log(c.yellow(` ${T.sendFailed[r.reason] || T.sendFailed.failed}\n`));
220
+ }
221
+
149
222
  console.log(c.bold(` ${T.how}`));
150
223
  console.log(` ${issueUrl(REPO_URL, T.issueTitle, body)}\n`);
151
224
  console.log(c.dim(` ${T.nothingSent}`));
225
+ console.log(c.dim(` ${T.sendHow(`${SELF} feedback --send`)}`));
152
226
  console.log(c.dim(` ${T.orPaste(`${SELF} feedback`)}\n`));
153
227
  // Код возврата всегда 0: команда, которая просит об одолжении и роняет при этом конвейер, —
154
228
  // последнее, что человек стерпит.
@@ -15,7 +15,7 @@ import {
15
15
  } from "../lib/repo.mjs";
16
16
  import { GATE_YML_TEMPLATE, CHECK_SH_TEMPLATE, README_TEMPLATE } from "../lib/templates.mjs";
17
17
  import { L } from "../i18n/index.mjs";
18
- import { gateCommand } from "../lib/execution.mjs";
18
+ import { gateCommand, gateTimeout } from "../lib/execution.mjs";
19
19
 
20
20
  // Ставит гейт из каталога в проект. Проверка КОПИРУЕТСЯ в репозиторий, а не остаётся
21
21
  // ссылкой в пакет: при установке через npx пакет временный, и завтра команда в манифесте
@@ -43,7 +43,11 @@ async function installGate(slug, man, facts) {
43
43
 
44
44
  // Общий список исключений едет вместе с проверкой: без него она читает окружение и
45
45
  // зависимости, и человек получает тысячу чужих нарушений вместо сотни своих.
46
- for (const helper of ["_skip.sh", "_native.sh"]) {
46
+ // `_target.sh` добавлен 2026-09-15 вместе с общим швом «судим только то, что можем разрешить».
47
+ // Без него скопированный гейт выходит с кодом 2 — это честно, но у человека он не работает.
48
+ // Поймано нашим же смоуком в день правки: список помощников тут — единственное место, и
49
+ // забыть его значит выпустить гейт, который у чужого проекта не запускается вовсе.
50
+ for (const helper of ["_skip.sh", "_native.sh", "_target.sh"]) {
47
51
  const from = join(GATES_SRC, helper);
48
52
  if (await exists(from)) await copyFile(from, join(CWD, PROJECT_GATES, helper));
49
53
  }
@@ -170,8 +174,10 @@ async function cmdNew(args) {
170
174
  // зовут установленные записи каталога. В самом комплекте оригинал уже лежит на месте (../),
171
175
  // в чужом проекте его никто не клал, пока не было ни одной установленной записи через `add`.
172
176
  if (!inKit) {
173
- const skipSrc = join(GATES_SRC, "_skip.sh");
174
- if (await exists(skipSrc)) await copyFile(skipSrc, join(CWD, PROJECT_GATES, "_skip.sh"));
177
+ for (const helper of ["_skip.sh", "_target.sh"]) {
178
+ const from = join(GATES_SRC, helper);
179
+ if (await exists(from)) await copyFile(from, join(CWD, PROJECT_GATES, helper));
180
+ }
175
181
  }
176
182
 
177
183
  console.log(c.bold(`\naqk new ${slug}\n`));
@@ -231,7 +237,7 @@ async function cmdRatchet(args) {
231
237
 
232
238
  // Снимок текущих нарушений — это и есть долг. Ключ без номера строки: правка соседней
233
239
  // строки не должна читаться как новое нарушение.
234
- const r = spawnSync(gateCommand(inner), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
240
+ const r = spawnSync(gateCommand(inner), { shell: true, cwd: CWD, encoding: "utf8", timeout: gateTimeout().ms });
235
241
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
236
242
  die(L.ratchet.notRunnable(slug, inner));
237
243
  }
@@ -439,7 +445,7 @@ async function cmdWhy(args) {
439
445
 
440
446
  // --- 3. объявлен: спрашиваем у него самого ---------------------------------
441
447
  console.log(c.dim(` ${L.why.declaredAs(cmd)}`));
442
- const r = spawnSync(gateCommand(String(cmd)), { shell: true, cwd: CWD, encoding: "utf8", timeout: 300000 });
448
+ const r = spawnSync(gateCommand(String(cmd)), { shell: true, cwd: CWD, encoding: "utf8", timeout: gateTimeout().ms });
443
449
  const ci = await runsInCi(slug, String(cmd));
444
450
 
445
451
  if (r.status === 127 || (r.error && r.error.code === "ENOENT")) {
@@ -215,11 +215,27 @@ async function cmdReport() {
215
215
 
216
216
  const text = lines.join("\n") + "\n";
217
217
  const dst = join(CWD, TARGET_DIR, "report.md");
218
- await mkdir(join(CWD, TARGET_DIR), { recursive: true });
219
- await writeFile(dst, text, "utf8");
218
+
219
+ // ОТЧЁТ ЭТО ЧТЕНИЕ, И ПАДАТЬ ЕМУ НЕ НА ЧЕМ. Разбор чужой интеграции 2026-09-16: в рабочей
220
+ // области, где `.aqk/` не создать, команда падала с EROFS, не напечатав ни строки. То есть
221
+ // побочное действие, о котором не просили, отменяло то, ради чего команду звали. Такие
222
+ // области бывают не по недосмотру: read-only контейнер, чужой конвейер, каталог под ревью.
223
+ //
224
+ // Молчать о несохранённом тоже нельзя: человек, увидевший отчёт, вправе считать, что файл
225
+ // лежит на диске, — если ему не сказали иначе. Тот же принцип, что и везде: не смогли —
226
+ // назови, а не выдавай за сделанное.
227
+ let saveError = null;
228
+ try {
229
+ await mkdir(join(CWD, TARGET_DIR), { recursive: true });
230
+ await writeFile(dst, text, "utf8");
231
+ } catch (e) {
232
+ saveError = e?.code || String(e?.message || e);
233
+ }
220
234
 
221
235
  console.log("\n" + text);
222
- console.log(c.dim(` ${L.report2.saved(join(TARGET_DIR, "report.md"))}\n`));
236
+ console.log(saveError
237
+ ? c.yellow(` ${L.report2.notSaved(join(TARGET_DIR, "report.md"), saveError)}\n`)
238
+ : c.dim(` ${L.report2.saved(join(TARGET_DIR, "report.md"))}\n`));
223
239
 
224
240
  // Код возврата — для конвейера и для агента: отчёт с красным гейтом не должен читаться
225
241
  // как «всё в порядке» только потому, что команда выполнилась.
@@ -45,10 +45,16 @@ function vitalsRows(f) {
45
45
  ok: f.unparsed > 0 ? false : true,
46
46
  detail: f.unparsed > 0 ? t.manifestBad(f.unparsed) : t.manifestOk,
47
47
  },
48
+ // ЧЕТЫРЕ ОТВЕТА ПРО ХУК. «Хук чужой» — не «хука нет»: в одном случае ставить нечего, в
49
+ // другом дописать строку в уже стоящий. Совет разный, значит и строка разная. Ни то ни
50
+ // другое не отказ: AQK могут сознательно гонять в конвейере.
48
51
  {
49
52
  key: "preCommit",
50
- ok: f.preCommit === null ? null : f.preCommit ? true : "no",
51
- detail: f.preCommit === null ? t.unknownHook : f.preCommit ? t.preCommitOk : t.preCommitNo,
53
+ ok: f.preCommit === null ? null : f.preCommit === true ? true : "no",
54
+ detail: f.preCommit === null ? t.unknownHook
55
+ : f.preCommit === true ? t.preCommitOk
56
+ : f.preCommit === "other" ? t.preCommitOther
57
+ : t.preCommitNo,
52
58
  },
53
59
  {
54
60
  key: "sessionHook",
@@ -110,7 +116,7 @@ async function cmdVitals() {
110
116
  // Найдено 2026-09-09 сверкой вывода двух команд на одном репозитории.
111
117
  const samplesDir = typeof man?.samples === "string" ? man.samples.trim() : "";
112
118
  for (const gate of Object.keys(gates)) {
113
- const missing = await gateRequires(samplesDir, gate, whichSync);
119
+ const missing = await gateRequires(man, samplesDir, gate, whichSync);
114
120
  for (const prog of missing || []) {
115
121
  if (seen.has(prog)) continue;
116
122
  seen.set(prog, { gate, prog, found: false });
@@ -26,6 +26,9 @@ const enDocs = {
26
26
  unknownHook: "could not look — unknown, not \"no\"",
27
27
  preCommitOk: "wired in .git/hooks",
28
28
  preCommitNo: "not in .git/hooks: a config entry is an intent, not a guard",
29
+ // A hook IS there, but not ours: nothing to install, something to add — a different
30
+ // remedy, hence a different line. Until 2026-09-16 this case printed as "wired".
31
+ preCommitOther: "a .git/hooks hook exists, but AQK is not called from it — check .pre-commit-config.yaml or the hook body",
29
32
  sessionOk: "the SessionStart hook hands the state to the agent",
30
33
  sessionNo: (cmd) => `the agent gets no state: ${cmd}`,
31
34
  versionOk: (v) => `${v}, current`,
@@ -83,7 +86,16 @@ const enDocs = {
83
86
  nextTitle: "Next, in order — computed from this repository, not generic advice:",
84
87
  nextStep: {
85
88
  init: () => "Set up the standard: `aqk init` — without .aqk.yml `aqk add` refuses.",
86
- adopt: (s) => `Declare the checks this project already has: in .aqk.yml, under gates: ${s.gates.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Then \`aqk doctor --run\`.`,
89
+ // Слабые НЕ ПОПАДАЮТ в «впиши в манифест»: агент исполняет написанное, и объявленная
90
+ // проверка с `|| true` — это дыра, за которую с этого дня ручается машина.
91
+ adopt: (s) => {
92
+ const ok = s.gates.filter((g) => !g.weak);
93
+ const weak = s.gates.filter((g) => g.weak);
94
+ const out = [];
95
+ if (ok.length) out.push(`Declare the checks this project already has: in .aqk.yml, under gates: ${ok.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Then \`aqk doctor --run\`.`);
96
+ if (weak.length) out.push(`These cannot go red as written — ${weak.map((g) => `${g.cmd} (${g.weak.text})`).join(", ")} — so do not declare them yet: tell the owner and fix the command first.`);
97
+ return out.join(" ");
98
+ },
87
99
  blind: (s) => `The "${s.slug}" defect the probe planted in ${s.file} was NOT caught by your checks.` +
88
100
  (s.command ? ` Catch it now: \`${s.command}\`.` : "") + ` Keep it caught: \`aqk add ${s.slug}\`.`,
89
101
  start: (s) => `Install ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (one line, no kit needed: \`${s.command}\`)` : "") +
@@ -93,7 +105,9 @@ const enDocs = {
93
105
  whenTitle: "When to do what:",
94
106
  whenCommit: (hook) => hook === true
95
107
  ? "Before a commit → the `.git/hooks/pre-commit` hook runs the checks itself; do not bypass it (`--no-verify`)."
96
- : "Before a commit → `aqk doctor --run --since main`: there is no pre-commit hook, it will not happen by itself.",
108
+ : hook === "other"
109
+ ? "Before a commit → `aqk doctor --run --since main`: a hook is installed in `.git/hooks`, but AQK is not called from it — the run will not happen by itself."
110
+ : "Before a commit → `aqk doctor --run --since main`: there is no pre-commit hook, it will not happen by itself.",
97
111
  whenRules: [
98
112
  "Added or changed a check → `aqk prove`: the gate must go red on its own red sample, otherwise it checks nothing.",
99
113
  "Writing a rule into the rulebook → put the arbiter mark next to it, `<!-- aqk: gate-name -->`; no gate — `<!-- aqk: human -->`, which is an admission, not a check.",
@@ -155,6 +169,7 @@ const enDocs = {
155
169
  whyTitle: "Why this matters — briefly",
156
170
  whyNothing: "nothing to add: everything applicable is in place",
157
171
  saved: (path) => `Saved: ${path}`,
172
+ notSaved: (f, why) => `could not save to ${f} (${why}) — the report is above, there is no file on disk. The command only reports state, so a failed write does not fail it.`,
158
173
  docs: {
159
174
  baseline: "the minimum a project needs, independent of language",
160
175
  readyMade: "the map of off-the-shelf rules: look for a ready one before writing your own",
@@ -218,6 +233,7 @@ const enDocs = {
218
233
  ],
219
234
  report: {
220
235
  skippedBySelect: "not run (by --only/--skip), state unknown",
236
+ notWritten: (f, why) => `could not write the run report to ${f} (${why}) — the gates did run, but the next session and the hook will read this as "no run happened".`,
221
237
  title: "aqk doctor --run",
222
238
  version: "version",
223
239
  level: "level",
@@ -194,8 +194,16 @@ export const enGates = {
194
194
  red: (names, cmd) =>
195
195
  `The kit caught: ${names}. One line to the author — did it help: ${cmd}`,
196
196
  once: "Asked once per project; switch it off with AQK_FEEDBACK=0",
197
- agent: "Say this to the human in one sentence, and do not insist if they do not answer.",
197
+ agent: "Say this to the human in one sentence, and do not insist if they do not answer. If they want to send it, add `--send`; never add the flag on your own.",
198
198
  },
199
+ sending: "sending on your behalf — with your own gh account…",
200
+ sent: (url) => `sent: ${url}\n That is the whole price of this kit. Thank you.`,
201
+ sendFailed: {
202
+ "no-auth": "could not send: gh is missing or you are not logged in (`gh auth login`). The link below always works.",
203
+ "no-thread": "could not send: the discussion was not found. Use the link below.",
204
+ failed: "could not send. The link below always works.",
205
+ },
206
+ sendHow: (cmd) => `Send it in one command, with your own GitHub account: ${cmd}`,
199
207
  how: "Sending is one click (a prefilled issue opens; the text stays editable):",
200
208
  issueTitle: "Feedback on the kit",
201
209
  nothingSent: "Nothing was sent: the kit makes no outgoing requests other than the version check.",
package/tool/i18n/en.mjs CHANGED
@@ -51,6 +51,7 @@ export const en = {
51
51
  learn: "rule candidates from local transcripts: said out loud, never written down",
52
52
  context: "the project state in one block — for an agent's context, not for reading",
53
53
  feedback: "a report on how the kit worked plus a prefilled link — the only payment it asks",
54
+ feedbackSend: "send the feedback in one command — with your own gh account; without the flag nothing leaves",
54
55
  vitals: "is what the kit runs on wired up: gate tools, hooks, version freshness",
55
56
  prompt: "one task for the agent: what to fix, in order, and how to prove it is done",
56
57
  contextInstall: "the same in full — the map and the rulebook — installed as a hook",
@@ -153,7 +154,17 @@ export const en = {
153
154
  startHook: "Running these by hand is a one-off. To have them run before every push: pre-commit (repo: https://github.com/arsen-ask-lx/Agent_Quality_Kit, hooks aqk / aqk-doctor), or a plain .git/hooks/pre-push.",
154
155
  haveAlready: (n) => `Checks you ALREADY have (${n}) — found in your own files, not invented:`,
155
156
  haveAlreadyHow: (line) => `declare them and a machine holds them, not your attention. In .aqk.yml, under gates: ${line}`,
156
- total: "Total:",
157
+ // Проверка, которая у проекта ЕСТЬ и провалиться НЕ МОЖЕТ. Самое ценное, что мы знаем о
158
+ // чужом репозитории в первые пять секунд, — и до 2026-09-14 мы об этом молчали.
159
+ weakOff: (text) => `cannot fail: the verdict is swallowed right in the script — «${text}»`,
160
+ weakZero: () => "cannot fail: --exit-zero tells the tool to exit 0 whatever it finds",
161
+ weakStub: (text) => `proves nothing: the whole script is a printout — «${text}»`,
162
+ weakHow: (n) => `${n} of them cannot go red. Declaring a check that cannot fail only makes the ` +
163
+ "silence machine-readable — fix the command first, then declare it.",
164
+ // Counted over the AQK CATALOGUE, and the line says so: a project gate named its own way
165
+ // closes none of our entries by name, and "held by a machine 0" read as a verdict on a
166
+ // repository with thirty checks of its own. Measured 2026-09-16 across twelve repositories.
167
+ total: "AQK catalogue totals:",
157
168
  totalHeld: (n) => `held by a machine ${n}`,
158
169
  totalTodo: (n) => `applicable but not installed ${n}`,
159
170
  totalSkip: (n) => `hidden ${n}`,
@@ -168,6 +179,7 @@ export const en = {
168
179
  `advisory and red: ${names.join(", ")}. These are switched-off checks: ` +
169
180
  `either fix them and drop them from advisory, or admit the rule does not exist.`,
170
181
  runHeading: "Running the declared gates",
182
+ timeoutBadEnv: (raw, secs) => `AQK_GATE_TIMEOUT="${raw}" is not a number of seconds above zero; waiting ${secs}s as before. Zero and garbage mean "wait forever" to spawnSync, and a hung gate looks exactly like a working one.`,
171
183
  timeout: "did not finish within 5 minutes",
172
184
  cannotCheck: (why) => `could not check: ${why}`,
173
185
  whySpawn: (code) => `failed to start${code ? ` (${code})` : ""}`,
@@ -294,7 +306,17 @@ export const en = {
294
306
  missed: ({ slug, file }, s) => `Gate \`${slug}\` is installed but missed the defect the probe planted into \`${file}\`. Find out why — a common cause is that the gate does not look at this file type or folder; \`${s} probe\` has the details. Done — \`${s} probe\` no longer names this class.`,
295
307
  red: (name, s) => `Gate \`${name}\` is red. Run \`${s} doctor --run --only ${name}\`, read the findings and fix the code. Done — that command passes.`,
296
308
  blind: ({ slug, file, command }, s) => `The probe planted a \`${slug}\` defect into \`${file}\` and the project's checks did not notice. Add a check: \`${s} add ${slug}\`${command ? ` (the same as one line, without the kit: \`${command}\`)` : ""}. Done — \`${s} doctor --run --only ${slug}\` passes and \`${s} probe\` no longer names this class.`,
297
- adopt: (gates, s) => `The project already has its own checks: ${gates.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Declare them under gates: in .aqk.yml — ${gates.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Done — \`${s} doctor --run\` runs them.`,
309
+ // СЛАБЫЕ ОТДЕЛЯЮТСЯ ОТ РАБОЧИХ, и это не украшение вывода. Агент исполняет написанное:
310
+ // велеть ему «впиши эти проверки в манифест», когда одна из них `… || true`, значит руками
311
+ // агента превратить дыру в зелёную галочку — и с этого дня её будет держать машина.
312
+ adopt: (gates, s) => {
313
+ const weak = gates.filter((g) => g.weak);
314
+ const ok = gates.filter((g) => !g.weak);
315
+ const out = [];
316
+ if (ok.length) out.push(`The project already has its own checks: ${ok.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Declare them under gates: in .aqk.yml — ${ok.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Done — \`${s} doctor --run\` runs them.`);
317
+ if (weak.length) out.push(`These CANNOT go red as written: ${weak.map((g) => `\`${g.cmd}\` — ${g.weak.text}`).join(", ")}. Fix the command before declaring it: a declared check that cannot fail turns a hole into a green tick, and from then on a machine vouches for it. This is the owner's call, not yours — say what you found.`);
318
+ return out.join(" ");
319
+ },
298
320
  shim: {
299
321
  missing: (s) => `Claude Code is set up here, but the rules live in AGENTS.md — it only reads CLAUDE.md. Create a CLAUDE.md with the single line \`@AGENTS.md\`. Done — \`${s} doctor\` no longer warns about it.`,
300
322
  noImport: (s) => `CLAUDE.md does not import AGENTS.md — Claude Code only sees CLAUDE.md. Add the line \`@AGENTS.md\` to CLAUDE.md (mentioning the file in prose does not load it). Done — \`${s} doctor\` no longer warns about it.`,
@@ -26,6 +26,10 @@ const ruDocs = {
26
26
  unknownHook: "посмотреть не смогли — неизвестно, а не «нет»",
27
27
  preCommitOk: "прописан в .git/hooks",
28
28
  preCommitNo: "в .git/hooks его нет: запись в конфиге — намерение, а не защита",
29
+ // ХУК ЕСТЬ, НО НЕ НАШ. Ставить нечего — надо дописать: совет другой, значит и строка
30
+ // другая. До 2026-09-16 этот случай печатался как «прописан», и репозиторий, где AQK не
31
+ // вызывался ни разу, выглядел подключённым.
32
+ preCommitOther: "хук в .git/hooks есть, но AQK в нём не вызывается — проверьте .pre-commit-config.yaml либо само тело хука",
29
33
  sessionOk: "хук SessionStart отдаёт состояние агенту",
30
34
  sessionNo: (cmd) => `агент не получает состояние: ${cmd}`,
31
35
  versionOk: (v) => `${v}, свежая`,
@@ -87,7 +91,14 @@ const ruDocs = {
87
91
  // complexity-limit её поймал.
88
92
  nextStep: {
89
93
  init: () => "Заведи стандарт: `aqk init` — без .aqk.yml `aqk add` откажет.",
90
- adopt: (s) => `Объяви проверки, которые у проекта уже есть: в .aqk.yml, в gates: ${s.gates.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Затем \`aqk doctor --run\`.`,
94
+ adopt: (s) => {
95
+ const ok = s.gates.filter((g) => !g.weak);
96
+ const weak = s.gates.filter((g) => g.weak);
97
+ const out = [];
98
+ if (ok.length) out.push(`Объяви проверки, которые у проекта уже есть: в .aqk.yml, в gates: ${ok.map((g) => `${g.name}: "${g.cmd}"`).join(", ")}. Затем \`aqk doctor --run\`.`);
99
+ if (weak.length) out.push(`А эти в нынешнем виде покраснеть не могут — ${weak.map((g) => `${g.cmd} (${g.weak.text})`).join(", ")} — и объявлять их пока нельзя: скажи владельцу и почини команду.`);
100
+ return out.join(" ");
101
+ },
91
102
  blind: (s) => `Брак «${s.slug}», подсаженный пробой в ${s.file}, ваши проверки НЕ поймали.` +
92
103
  (s.command ? ` Поймать сейчас: \`${s.command}\`.` : "") + ` Держать всегда: \`aqk add ${s.slug}\`.`,
93
104
  start: (s) => `Поставь ${s.slug}: \`aqk add ${s.slug}\`` + (s.command ? ` (одной строкой, без комплекта: \`${s.command}\`)` : "") +
@@ -97,7 +108,9 @@ const ruDocs = {
97
108
  whenTitle: "Когда что делать:",
98
109
  whenCommit: (hook) => hook === true
99
110
  ? "Перед коммитом → хук `.git/hooks/pre-commit` прогонит проверки сам; не обходи его (`--no-verify`)."
100
- : "Перед коммитом → `aqk doctor --run --since main`: хука перед коммитом нет, само это не случится.",
111
+ : hook === "other"
112
+ ? "Перед коммитом → `aqk doctor --run --since main`: хук в `.git/hooks` стоит, но AQK из него не вызывается — сам собой прогон не случится."
113
+ : "Перед коммитом → `aqk doctor --run --since main`: хука перед коммитом нет, само это не случится.",
101
114
  whenRules: [
102
115
  "Добавил или поменял проверку → `aqk prove`: гейт обязан покраснеть на своём красном образце, иначе он не проверяет ничего.",
103
116
  "Пишешь правило в свод → рядом метка арбитра `<!-- aqk: имя-гейта -->`; гейта нет — `<!-- aqk: человек -->`, и это признание, а не проверка.",
@@ -159,6 +172,7 @@ const ruDocs = {
159
172
  whyTitle: "Зачем это нужно — коротко",
160
173
  whyNothing: "нечего добавлять: применимое уже стоит",
161
174
  saved: (path) => `Сохранено: ${path}`,
175
+ notSaved: (f, why) => `сохранить в ${f} не смогли (${why}) — отчёт выше, на диске его нет. Команда только рассказывает о состоянии, поэтому неудачная запись её не роняет.`,
162
176
  docs: {
163
177
  baseline: "обязательный минимум проекта, без привязки к языку",
164
178
  readyMade: "карта готовых правил: сперва ищи готовое, потом пиши своё",
@@ -222,6 +236,7 @@ const ruDocs = {
222
236
  ],
223
237
  report: {
224
238
  skippedBySelect: "не запускался (по --only/--skip), состояние неизвестно",
239
+ notWritten: (f, why) => `отчёт прогона в ${f} записать не смогли (${why}) — гейты отработали, но следующая сессия и хук прочтут это как «прогона не было».`,
225
240
  title: "aqk doctor --run",
226
241
  version: "версия",
227
242
  level: "уровень",
@@ -197,8 +197,16 @@ export const ruGates = {
197
197
  red: (names, cmd) =>
198
198
  `Комплект поймал: ${names}. Одна строка автору — помогло или нет: ${cmd}`,
199
199
  once: "Спрашиваем один раз на проект; выключить: AQK_FEEDBACK=0",
200
- agent: "Скажи это человеку одной фразой и не настаивай, если он не ответил.",
200
+ agent: "Скажи это человеку одной фразой и не настаивай, если он не ответил. Захочет отправить — добавь `--send`, сам флаг не добавляй.",
201
201
  },
202
+ sending: "отправляю от вашего имени — вашей же учётной записью gh…",
203
+ sent: (url) => `отправлено: ${url}\n Это и есть вся плата за комплект. Спасибо.`,
204
+ sendFailed: {
205
+ "no-auth": "отправить не смогли: gh не установлен или вход не выполнен (`gh auth login`). Ниже — ссылка, она работает всегда.",
206
+ "no-thread": "отправить не смогли: обсуждение не найдено. Ниже — ссылка.",
207
+ failed: "отправить не смогли. Ниже — ссылка, она работает всегда.",
208
+ },
209
+ sendHow: (cmd) => `Отправить одной командой, своей учётной записью GitHub: ${cmd}`,
202
210
  how: "Отправить — одно нажатие (откроется готовая задача, текст можно править):",
203
211
  issueTitle: "Отзыв о работе комплекта",
204
212
  nothingSent: "Ничего не отправлено: комплект не делает исходящих запросов, кроме проверки версии.",
package/tool/i18n/ru.mjs CHANGED
@@ -54,6 +54,7 @@ export const ru = {
54
54
  learn: "кандидаты в правила из локальной переписки: сказано вслух и не записано",
55
55
  context: "состояние проекта одним блоком — для контекста агента, а не для чтения",
56
56
  feedback: "отчёт о работе комплекта и готовая ссылка — единственная плата за него",
57
+ feedbackSend: "отправить отзыв одной командой — вашей учётной записью gh; без флага не уходит ничего",
57
58
  vitals: "подключено ли то, чем комплект работает: инструменты гейтов, хуки, свежесть версии",
58
59
  prompt: "одно задание для агента: что починить, по порядку, и чем доказать, что готово",
59
60
  contextInstall: "то же самое, но целиком — карта и свод правил — и хуком в контекст",
@@ -159,7 +160,16 @@ export const ru = {
159
160
  startHook: "Прогнать руками — разовый героизм. Чтобы это случалось перед каждым пушем: pre-commit (репозиторий https://github.com/arsen-ask-lx/Agent_Quality_Kit, хуки aqk / aqk-doctor) либо обычный .git/hooks/pre-push.",
160
161
  haveAlready: (n) => `Проверки, которые у вас УЖЕ ЕСТЬ (${n}) — прочитаны в ваших файлах, не выдуманы:`,
161
162
  haveAlreadyHow: (line) => `объявите их — и держать будет машина, а не ваше внимание. В .aqk.yml, в gates: ${line}`,
162
- total: "Итого:",
163
+ weakOff: (text) => `не может провалиться: исход погашен прямо в скрипте — «${text}»`,
164
+ weakZero: () => "не может провалиться: --exit-zero велит инструменту выйти нулём, что бы он ни нашёл",
165
+ weakStub: (text) => `ничего не доказывает: всё тело скрипта — печать — «${text}»`,
166
+ weakHow: (n) => `из них ${n} покраснеть не могут. Объявить проверку, которая не может ` +
167
+ "провалиться, — значит сделать молчание машинно-читаемым. Сначала почините команду.",
168
+ // «Итого» — ПО КАТАЛОГУ AQK, и это сказано словом. Считаются наши записи: гейт проекта,
169
+ // названный по-своему (`project-verify`), ни одну из них по имени не закрывает, и строка
170
+ // «держит машина 0» у проекта с тридцатью собственными проверками читалась как приговор
171
+ // ему, а не как счёт наших записей. Замер 2026-09-16 по двенадцати репозиториям.
172
+ total: "Итого по каталогу AQK:",
163
173
  totalHeld: (n) => `держит машина ${n}`,
164
174
  totalTodo: (n) => `применимо но не поставлено ${n}`,
165
175
  totalSkip: (n) => `скрыто ${n}`,
@@ -174,6 +184,7 @@ export const ru = {
174
184
  `совещательные и красные: ${names.join(", ")}. Это выключенные проверки: ` +
175
185
  `либо почини и убери из advisory, либо признай, что правила нет.`,
176
186
  runHeading: "Прогон объявленных гейтов",
187
+ timeoutBadEnv: (raw, secs) => `AQK_GATE_TIMEOUT=«${raw}» — это не число секунд больше нуля; жду как прежде, ${secs}с. Ноль и мусор у spawnSync означают «ждать вечно», а висящий гейт неотличим от работающего.`,
177
188
  timeout: "не уложился в 5 минут",
178
189
  // «Не смогли проверить» — третье состояние, и оно обязано звучать иначе, чем находка:
179
190
  // «код 2» человек читает как приговор коду, а это приговор запуску.
@@ -310,7 +321,14 @@ export const ru = {
310
321
  missed: ({ slug, file }, s) => `Гейт \`${slug}\` стоит, но пропустил брак, который проба подсадила в \`${file}\`. Разберись почему — частая причина: гейт не смотрит этот тип файлов или каталог; подробности даст \`${s} probe\`. Готово — \`${s} probe\` больше не называет этот класс.`,
311
322
  red: (name, s) => `Гейт \`${name}\` красный. Запусти \`${s} doctor --run --only ${name}\`, прочитай находки и почини код. Готово — эта команда зелёная.`,
312
323
  blind: ({ slug, file, command }, s) => `Проба подсадила брак класса \`${slug}\` в \`${file}\`, и проверки проекта его не заметили. Поставь проверку: \`${s} add ${slug}\`${command ? ` (то же одной строкой, без комплекта: \`${command}\`)` : ""}. Готово — \`${s} doctor --run --only ${slug}\` проходит, а \`${s} probe\` больше не называет этот класс.`,
313
- adopt: (gates, s) => `У проекта уже есть свои проверки: ${gates.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Впиши их в gates: манифеста .aqk.yml — ${gates.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Готово — \`${s} doctor --run\` их запускает.`,
324
+ adopt: (gates, s) => {
325
+ const weak = gates.filter((g) => g.weak);
326
+ const ok = gates.filter((g) => !g.weak);
327
+ const out = [];
328
+ if (ok.length) out.push(`У проекта уже есть свои проверки: ${ok.map((g) => `\`${g.cmd}\` (${g.source})`).join(", ")}. Впиши их в gates: манифеста .aqk.yml — ${ok.map((g) => `\`${g.name}: "${g.cmd}"\``).join(", ")}. Готово — \`${s} doctor --run\` их запускает.`);
329
+ if (weak.length) out.push(`А эти в нынешнем виде покраснеть НЕ МОГУТ: ${weak.map((g) => `\`${g.cmd}\` — ${g.weak.text}`).join(", ")}. Не вписывай их, пока не починена команда: объявленная проверка, которая не может провалиться, превращает дыру в зелёную галочку, и с этого дня за неё ручается машина. Это решение владельца, а не твоё, — скажи ему, что нашёл.`);
330
+ return out.join(" ");
331
+ },
314
332
  shim: {
315
333
  missing: (s) => `Claude Code здесь настроен, а правила лежат в AGENTS.md — он читает только CLAUDE.md. Создай CLAUDE.md с одной строкой \`@AGENTS.md\`. Готово — \`${s} doctor\` об этом больше не предупреждает.`,
316
334
  noImport: (s) => `CLAUDE.md не подключает AGENTS.md — Claude Code видит только CLAUDE.md. Добавь в CLAUDE.md строку \`@AGENTS.md\` (упоминание словами файл не загружает). Готово — \`${s} doctor\` об этом больше не предупреждает.`,
@@ -20,24 +20,77 @@
20
20
  // ни другой. Берём только то, что отвечает «прошло или нет».
21
21
  const CHECK_NAMES = new Set(["test", "tests", "lint", "typecheck", "type-check", "types", "check"]);
22
22
 
23
+ // ПРОВЕРКА, КОТОРАЯ ЕСТЬ И НЕ МОЖЕТ ПРОВАЛИТЬСЯ. До 2026-09-14 мы читали ИМЯ скрипта и печатали
24
+ // каноничную команду `npm test` с зелёной галочкой, ни разу не заглянув в его ТЕЛО. А выключатель
25
+ // стоит именно там: `node --test || true`. На репозитории, где выключено всё, первый экран
26
+ // говорил «у вас уже есть 2 проверки» — ровно та ошибка, ради которой написан весь комплект.
27
+ //
28
+ // ГРАНИЦА ВЗЯТА У ГЕЙТА `ci-actually-fails`, дословно его довод: `|| true` в СЕРЕДИНЕ команды —
29
+ // это идемпотентность вспомогательного шага (`mkdir -p … || true`), а не выключенная проверка.
30
+ // Поэтому красным делается только то, под что попадает ВЕСЬ исход: гашение в конце тела либо
31
+ // флаг, у которого другого назначения нет.
32
+ const OFF_TAIL = /(\|\|\s*(?:true|:|exit\s+0)|;\s*(?:true|exit\s+0))\s*$/;
33
+ const ZERO_FLAG = /(?:^|\s)--exit-zero(?:\s|$)/;
34
+
35
+ function cannotFail(body) {
36
+ if (typeof body !== "string") return undefined;
37
+ // Комментарий — не команда. `\s#` (а не просто `#`): в `echo "#1"` решётка стоит внутри строки.
38
+ const code = body.replace(/\s#[^"']*$/, "").trim();
39
+ if (!code) return undefined;
40
+ const off = OFF_TAIL.exec(code);
41
+ if (off) return { kind: "off", text: off[1].trim() };
42
+ if (ZERO_FLAG.test(code)) return { kind: "zero", text: "--exit-zero" };
43
+ // Заглушка: всё тело — печать и ничего больше. Заготовка npm
44
+ // (`echo "Error: no test specified" && exit 1`) провалиться МОЖЕТ — её обвинять нельзя,
45
+ // и её отсекает связка `&&`.
46
+ if (/^echo\b/.test(code) && !/[&|;]/.test(code)) return { kind: "stub", text: code.slice(0, 60) };
47
+ return undefined;
48
+ }
49
+
23
50
  // Окружения tox, которые судят, а не гоняют тесты под матрицей версий. Голый `tox` не
24
51
  // предлагаем: он проходит все интерпретаторы из envlist, и у человека без пяти питонов это
25
52
  // красный прогон на пустом месте. Имена сняты с живых tox-файлов (rich, click, flask).
26
53
  const TOX_CHECKS = new Set(["lint", "style", "typing", "types", "type", "mypy", "check", "typecheck"]);
27
54
 
55
+ // ОДНА И ТА ЖЕ КОМАНДА, ЗАПИСАННАЯ ПО-РАЗНОМУ. Сравнивать надо КОМАНДЫ, а не имена: гейт в
56
+ // чужом манифесте зовётся как хочет его владелец (`project-verify`), и по именам совпадения не
57
+ // будет никогда. Обёртка снимается: `bash scripts/check` и `scripts/check` — это одно, и
58
+ // советовать второе тому, у кого объявлено первое, значит советовать уже сделанное.
59
+ //
60
+ // Вхождение подстрокой здесь НЕ годится, хотя и соблазняет: `true` содержится в половине
61
+ // команд, и любой гейт-заглушка съел бы весь список. Равенство после нормализации ошибается
62
+ // в одну сторону — покажет лишнее, — и это дешевле молчания.
63
+ const WRAPPER = /^(?:bash|sh)\s+/;
64
+
65
+ function sameCommand(a, b) {
66
+ const norm = (s) => String(s || "").trim().replace(/\s+/g, " ");
67
+ const bare = (s) => norm(s).replace(WRAPPER, "");
68
+ return (norm(a) && norm(a) === norm(b)) || (bare(a) && bare(a) === bare(b));
69
+ }
70
+
28
71
  // Источники в порядке доверия: при совпадении имени берётся первый. Замер 2026-09-11 на
29
72
  // восьми python-проектах: package.json нет ни у кого, Makefile у трёх, `.pre-commit-config.yaml`
30
73
  // у семи, tox у пяти, scripts/ у httpx. Шаги конвейера НЕ читаются: там `${{ matrix.x }}`,
31
74
  // `PYTHONPATH=…` и обёртки `uv run --locked --group …` — строка, которая работает только в
32
75
  // том конвейере, предложенная как локальный гейт, покраснела бы у человека в первую же минуту.
33
- function proposeGates(files = {}) {
76
+ //
77
+ // `declared` — команды, УЖЕ объявленные в манифесте. Приходят списком строк, а не манифестом:
78
+ // иначе чтение чужих конфигов начало бы зависеть от нашего формата, и файл, заведённый ради
79
+ // разбора ЧУЖИХ источников, получил бы вторую причину меняться.
80
+ function proposeGates(files = {}, declared = []) {
34
81
  const out = [];
35
82
  const seen = new Set();
36
- const push = (name, cmd, source) => {
83
+ const already = (cmd) => (declared || []).some((d) => sameCommand(d, cmd));
84
+ // ТЕЛО передаётся отдельно от команды: человеку показывается каноничная `npm test`, а судим
85
+ // мы по тому, что за ней стоит. Где тела у нас нет (Makefile, tox, scripts/) — там и суждения
86
+ // нет: молчание тут честнее догадки.
87
+ const push = (name, cmd, source, body) => {
37
88
  const key = name === "tests" ? "test" : name.replace(/^type-?check$|^types$/, "typecheck");
38
89
  if (seen.has(key)) return;
39
90
  seen.add(key);
40
- out.push({ name: key, cmd, source });
91
+ if (already(cmd)) return;
92
+ const weak = cannotFail(body);
93
+ out.push(weak ? { name: key, cmd, source, weak } : { name: key, cmd, source });
41
94
  };
42
95
 
43
96
  const pkg = files["package.json"];
@@ -48,7 +101,8 @@ function proposeGates(files = {}) {
48
101
  for (const name of Object.keys(scripts)) {
49
102
  if (!CHECK_NAMES.has(name)) continue;
50
103
  // `npm test` — каноничное написание для теста, остальное через `run`.
51
- push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`, "package.json");
104
+ push(name, name === "test" || name === "tests" ? "npm test" : `npm run ${name}`,
105
+ "package.json", scripts[name]);
52
106
  }
53
107
  }
54
108
  }