@dzhechkov/p-replicator 1.6.0 → 1.10.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 (48) hide show
  1. package/.dz-manifest.json +92 -32
  2. package/CHANGELOG.md +176 -0
  3. package/README.md +106 -4
  4. package/package.json +4 -4
  5. package/sbom.json +181 -31
  6. package/src/commands/doctor.js +43 -31
  7. package/src/commands/verify.js +27 -2
  8. package/src/utils.js +27 -0
  9. package/templates/.claude/commands/myinsights.md +22 -5
  10. package/templates/.claude/commands/replicate.md +57 -1
  11. package/templates/.claude/hooks/check-docs-complete.cjs +202 -0
  12. package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
  13. package/templates/.claude/hooks/check-ports.cjs +36 -4
  14. package/templates/.claude/hooks/statusline.cjs +16 -5
  15. package/templates/.claude/rules/replicate-pipeline.md +14 -4
  16. package/templates/.claude/rules/skill-interface-protocol.md +9 -0
  17. package/templates/.claude/skills/brutal-honesty-review/scripts/assess-code.sh +40 -7
  18. package/templates/.claude/skills/brutal-honesty-review/scripts/assess-tests.sh +38 -11
  19. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +6 -4
  20. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
  21. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-hooks-commands.md +40 -4
  22. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +2 -2
  23. package/templates/.claude/skills/requirements-validator/SKILL.md +52 -0
  24. package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
  25. package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
  26. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
  27. package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
  28. package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
  29. package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
  30. package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
  31. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +9 -9
  32. package/tests/snapshot/baseline.json +25 -23
  33. package/tests/unit/absence-is-not-emptiness.test.js +255 -0
  34. package/tests/unit/assess-scripts.test.js +150 -0
  35. package/tests/unit/check-docs-complete.test.js +292 -0
  36. package/tests/unit/check-growth-trace.test.js +188 -0
  37. package/tests/unit/check-ports.test.js +99 -0
  38. package/tests/unit/generated-guard-templates.test.js +134 -0
  39. package/tests/unit/growth-axes-and-compliance.test.js +169 -0
  40. package/tests/unit/growth-gate-conditional.test.js +122 -0
  41. package/tests/unit/growth-module-b2b-gate.test.js +20 -2
  42. package/tests/unit/growth-requirements-bridge.test.js +127 -0
  43. package/tests/unit/guard-forms.test.js +302 -0
  44. package/tests/unit/insights-docs-tell-the-truth.test.js +84 -0
  45. package/tests/unit/module-copy-identity.test.js +106 -0
  46. package/tests/unit/shipped-suite-context.test.js +142 -0
  47. package/tests/unit/skill-paths-prebaked.test.js +174 -0
  48. package/tests/unit/sync-templates-guard.test.js +31 -1
package/src/utils.js CHANGED
@@ -94,6 +94,30 @@ function copyDirFiltered(src, dest, filterFn) {
94
94
  /**
95
95
  * Returns true if the path exists.
96
96
  */
97
+ /**
98
+ * Three states, because two were not enough.
99
+ *
100
+ * `fileExists` answers about PRESENCE and is deliberately left alone — it has 31 call sites, several
101
+ * of which ask a genuine presence question about files that may legitimately hold nothing. This is
102
+ * the predicate for the different question: is this artifact USABLE?
103
+ *
104
+ * MEASURED 2026-08-27: 31 artifacts truncated to zero bytes — every SKILL.md, command, rule and
105
+ * agent — and both `verify` and `doctor` reported clean with exit 0. Deleting one was caught. The
106
+ * gap was exactly this: accessSync asks whether the path resolves, never what is in it.
107
+ *
108
+ * Whitespace counts as empty. A file holding a newline is exactly as dead as one holding nothing,
109
+ * and a size check alone would pass it.
110
+ */
111
+ function artifactState(filePath) {
112
+ let body;
113
+ try {
114
+ body = fs.readFileSync(filePath, 'utf-8');
115
+ } catch {
116
+ return 'missing';
117
+ }
118
+ return body.trim().length === 0 ? 'empty' : 'present';
119
+ }
120
+
97
121
  function fileExists(filePath) {
98
122
  try {
99
123
  fs.accessSync(filePath);
@@ -334,6 +358,8 @@ const COMPONENTS = {
334
358
  'statusline': 'Multi-line dashboard (pipeline, roadmap, toolkit) for Claude Code statusLine',
335
359
  'state-update': 'Argv-driven helper for pipeline commands to publish current command + phase + progress',
336
360
  'check-ports': 'Enforce docker-ports Правило №0 against a real compose (invoke deliberately; exits 0/1/2)',
361
+ 'check-growth-trace': 'Did the M5 growth seed reach docs/Specification.md (invoke deliberately; exits 0/1/2)',
362
+ 'check-docs-complete': 'Are Phase-1 documents written and placeholder-free, before the Phase-2 swarm (invoke deliberately; exits 0/1/2)',
337
363
  },
338
364
  },
339
365
  // ─── Project-generated groups (created by /replicate Phase 3) ───────────
@@ -522,6 +548,7 @@ function getItemRelativePath(comp, itemKey) {
522
548
  // ===========================================================================
523
549
 
524
550
  module.exports = {
551
+ artifactState,
525
552
  // Colors
526
553
  green, red, yellow, blue, cyan, bold, dim, gray,
527
554
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Capture and recall development insights. Append a new insight to `.claude/insights/index.md` with structured fields (problem, solution, tags). Auto-injected into context on SessionStart for relevant tasks.
2
+ description: Capture and recall development insights. Append a new insight to `.claude/insights/index.md` with structured fields (problem, solution, tags). The three most recent are injected into context at SessionStart.
3
3
  argument-hint: '[recall <query> | <free-form insight>]'
4
4
  ---
5
5
 
@@ -8,9 +8,24 @@ argument-hint: '[recall <query> | <free-form insight>]'
8
8
  ## Purpose
9
9
 
10
10
  Build a project-local knowledge base of "грабли" (rakes) — errors, workarounds,
11
- discoveries — so they don't have to be re-learned. Insights are auto-loaded
12
- into Claude Code context on each session start (via `SessionStart` hook in
13
- `.claude/settings.json`) when their tags match the current task.
11
+ discoveries — so they don't have to be re-learned.
12
+
13
+ **What actually happens, stated exactly.** The `SessionStart` hook
14
+ (`.claude/hooks/session-insights.cjs`, wired in `.claude/settings.json`) reads
15
+ `.claude/insights/index.md` and injects the **three most recent entries**, by their
16
+ order in the file. It prints them under the heading *"Recent project insights"* —
17
+ which is what they are.
18
+
19
+ **There is no tag matching, and it is not an omission.** The hook fires at
20
+ `SessionStart`, BEFORE you have said anything, so there is no current task to match
21
+ tags against. Tags remain useful for a human reading or grepping the file, and for
22
+ `/myinsights recall <query>`, which searches on demand — when a query exists.
23
+
24
+ **The consequence, so nobody is surprised by it.** The file is append-only and the
25
+ hook takes the LAST three. As a project accumulates entries — `insights-capture.md`
26
+ plans for 50+ — earlier ones stop being injected. Selection by relevance would need
27
+ to happen at a moment when a task is known; that is a separate design question, and
28
+ it is filed rather than quietly implied here.
14
29
 
15
30
  ## Modes
16
31
 
@@ -69,4 +84,6 @@ mistakes without manual recall.
69
84
 
70
85
  - `.claude/rules/insights-capture.md` — when/how to capture
71
86
  - `.claude/hooks/session-insights.cjs` — session injection
72
- - `/harvest` — extracts reusable patterns from insights at project end
87
+ - `/harvest` — extracts reusable knowledge at project end. **Honest limit:** it does
88
+ NOT read `.claude/insights/index.md` today (`grep -ci insight` over `harvest.md`
89
+ returns 0). The capture→harvest link is a stated intention, not a wired path.
@@ -181,7 +181,22 @@ discovered: a B2B run now spends M5's time.
181
181
  Honest limit: line 62 of the module says to *switch* to a sales-led framework; it does not define
182
182
  one. A B2B run gets the type-appropriate instruction, not a type-appropriate playbook.
183
183
 
184
- **Output:** Product Discovery Brief passed as pre-filled context to Phase 1
184
+ **Output WRITE FIRST, then hand off. Both, in this order:**
185
+
186
+ 1. **Write** the full Product Discovery Brief to `docs/product-discovery-brief.md`.
187
+ 2. **Then** pass it to Phase 1 as pre-filled context, exactly as before.
188
+
189
+ The hand-off is unchanged; the file is an ADDITION. Until this was added the brief existed only in
190
+ the conversation — it made Phase 1 skip its own Phase 0 (`sparc-prd-mini/SKILL.md:993-999`) and then
191
+ vanished, so the M5 growth analysis had no artifact any later step could read. Nothing downstream was
192
+ ignoring it: there was nothing to ignore.
193
+
194
+ The file MUST include M5's `Growth Requirements Seed` table verbatim when M5 ran — that table is the
195
+ only place `FR-GROWTH-nnn` obligations exist before Phase 1 promotes them.
196
+
197
+ **When this file is absent it means Phase 0 did not run** — the `--from-docs` / `--skip-discovery`
198
+ entry skips Phase 0 entirely (see the alternative-entry section). Absence is NOT evidence that the
199
+ project has no growth requirements, and no consumer may read it that way.
185
200
 
186
201
  **Checkpoint:**
187
202
  ```
@@ -253,8 +268,49 @@ Created [N] documents in docs/
253
268
  ═══════════════════════════════════════════════════════════════
254
269
  ```
255
270
 
271
+ ### Прерванный прогон: как продолжить с того места
272
+
273
+ `/replicate` — интерактивный конвейер с четырьмя чекпоинтами, и продолжить его можно **уже сейчас,
274
+ без всякой новой машинерии**. Три сигнала, каждый существует независимо от этого раздела:
275
+
276
+ | Вопрос | Чем отвечается |
277
+ |---|---|
278
+ | До какой фазы дошли? | `git log --oneline` — после КАЖДОЙ фазы делается свой коммит (`docs: SPARC…`, `docs: validation report…`, `feat: Claude Code toolkit…`, `chore: initial project setup…`) |
279
+ | Документы Фазы 1 дописаны? | `node .claude/hooks/check-docs-complete.cjs .` — `0` дописаны, `1` названо, чего не хватает, `2` Фаза 1 не запускалась |
280
+ | Тулкит Фазы 3 сгенерирован? | `npx @dzhechkov/p-replicator verify` — раздел «Post-/replicate» |
281
+
282
+ **Как продолжить:** посмотрите последний коммит фазы, затем скажите `/replicate` прямым текстом:
283
+ *«продолжай с Фазы 3, Фазы 0-2 уже сделаны»*. Конвейер интерактивный — человек на чекпоинте и есть
284
+ механизм возобновления.
285
+
286
+ **Почему здесь нет автоматического определения фазы.** Оно рассматривалось (бэклог `58575b07`) и
287
+ сознательно НЕ реализовано: три сигнала выше уже дают ответ, а свежая логика ветвления в
288
+ интерактивном конвейере — это то, что может сработать неверно ровно тогда, когда прогон и так пошёл
289
+ не по плану. Запись решения важнее самого решения: если вы вернётесь к этому вопросу, начинайте с
290
+ того, что перечисленного выше оказалось недостаточно.
291
+
256
292
  ### Phase 2: VALIDATION
257
293
 
294
+ **Шаг 2.0 — ДЕТЕРМИНИРОВАННАЯ ПРОВЕРКА ПОЛНОТЫ. Выполняется ПЕРВОЙ, до запуска роя.**
295
+
296
+ ```bash
297
+ node .claude/hooks/check-docs-complete.cjs .
298
+ ```
299
+
300
+ | Код | Что делать |
301
+ |:---:|---|
302
+ | `0` | продолжайте — рой валидации запускается |
303
+ | `1` | **НЕ запускайте рой.** Вернитесь в Фазу 1 и допишите названные документы |
304
+ | `2` | проверка не выполнена — почините вызов и повторите; это НЕ «всё в порядке» |
305
+
306
+ Причина, по которой шаг стоит здесь, а не внутри роя: существование файла, его пустота и
307
+ незаполненный шаблон решаются сорока строками кода. Отправлять на этот вопрос рой агентов — значит
308
+ платить вероятностной проверкой за то, что решается детерминированно. Рою остаётся то, ради чего он
309
+ и нужен: тестируемость, полнота требований, реализуемость.
310
+
311
+ Ограничение, которое проверка печатает сама: она доказывает, что документы НАПИСАНЫ, а не что они
312
+ верны. Верность — работа роя.
313
+
258
314
  Read the skill: `.claude/skills/requirements-validator/SKILL.md`
259
315
 
260
316
  **Goal:** Verify all documentation for completeness, testability, and implementation readiness.
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-docs-complete.cjs — the cheap question, asked before the expensive one.
6
+ *
7
+ * Phase 2 of /replicate launches a SWARM of validation agents over whatever Phase 1 produced. Between
8
+ * the two there was nothing: existence, emptiness and unfilled placeholders are decidable by this
9
+ * script, and sending a multi-agent swarm to discover them is a layer-1 check living at layer 3.
10
+ *
11
+ * NOT an event hook, like `state-update.cjs`, `check-ports.cjs` and `check-growth-trace.cjs`. This
12
+ * package's hooks are NON-BLOCKING by contract, so a hook could print but never refuse. Invoke it:
13
+ *
14
+ * node .claude/hooks/check-docs-complete.cjs [path-to-project]
15
+ *
16
+ * Exit codes:
17
+ * 0 every required document exists, has content, and carries no unfilled placeholder
18
+ * 1 a NAMED document is missing, empty, or still a template
19
+ * 2 THE CHECK DID NOT RUN — no docs/ directory, or it could not be read
20
+ *
21
+ * Honest limit, and it is printed on the passing path: this proves the documents were WRITTEN, not
22
+ * that they are correct. Correctness is what the Phase-2 swarm is for; this only stops the swarm
23
+ * being spent discovering an empty file.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ /** What Phase 1 writes. `optional: true` mirrors replicate.md's own `(if applicable)`. */
30
+ const DOCS = [
31
+ { file: 'PRD.md' },
32
+ { file: 'Solution_Strategy.md' },
33
+ { file: 'Specification.md' },
34
+ { file: 'Pseudocode.md' },
35
+ { file: 'Architecture.md' },
36
+ { file: 'Refinement.md' },
37
+ { file: 'Completion.md' },
38
+ { file: 'Research_Findings.md' },
39
+ // REPORTED, not required. MEASURED 2026-08-27 against a real completed /replicate project:
40
+ // 8 of 9 promised documents were produced and this one was NOT, though replicate.md and
41
+ // sparc-prd-mini both promise it (three places, including a whole SYNTHESIS phase). One project
42
+ // is not enough evidence to decide whether the pipeline is broken or the document is optional in
43
+ // practice — and blocking on it would have refused every project that ran like that one.
44
+ // The discrepancy is filed; until it is settled this reports rather than refuses.
45
+ { file: 'Final_Summary.md', optional: true, expected: true },
46
+ { file: 'C4_Diagrams.md', optional: true },
47
+ { file: 'ADR.md', optional: true },
48
+ ];
49
+
50
+ /** Below this a document has a heading and nothing under it. */
51
+ const MIN_CHARS = 200;
52
+
53
+ function say(s) { process.stdout.write(s + '\n'); }
54
+
55
+ function cannotCheck(reason, hint) {
56
+ say('⚠️ проверка НЕ выполнена: ' + reason);
57
+ if (hint) say(' ' + hint);
58
+ process.exit(2);
59
+ }
60
+
61
+ /**
62
+ * Unfilled placeholders — and the reason this is split into two confidence levels.
63
+ *
64
+ * The first version used one rule: a bracketed token not followed by `(`. Cross-family review
65
+ * destroyed it with two inputs, both REPRODUCED before this rewrite:
66
+ *
67
+ * 1. `A[Web App]` — a mermaid node. The BUNDLED sparc-prd-mini skill REQUIRES mermaid diagrams in
68
+ * Architecture.md (SKILL.md:570-583), so the gate blocked every normally generated project.
69
+ * 2. `[GAP: needs performance targets]` — which Phase 1 writes DELIBERATELY in --from-docs mode for
70
+ * Phase 2 to resolve (replicate.md:82-94). Blocking on it deadlocks the documented flow: the
71
+ * only step that can clear the marker is the one the gate refuses to start.
72
+ *
73
+ * The lesson is about CONFIDENCE, not about patterns. A false block here stops the whole pipeline,
74
+ * which is worse than a missed placeholder the Phase-2 swarm would have caught anyway. So:
75
+ *
76
+ * BLOCKING — shapes that cannot be anything else: `{{…}}`, TODO/TBD/XXX/FIXME.
77
+ * WARNING — bracketed prose. Reported by name, never blocking, because this script cannot tell
78
+ * `[описание продукта]` from a diagram label or a citation without understanding the
79
+ * document, and guessing wrong costs more than staying quiet.
80
+ */
81
+
82
+ /** Fenced code blocks hold mermaid, arrays and code — none of it prose, none of it ours to judge. */
83
+ function stripFences(body) {
84
+ return body.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '');
85
+ }
86
+
87
+ const BLOCKING = [
88
+ { re: /\{\{[^}\n]{1,80}\}\}/g, what: 'незаполненный {{шаблон}}' },
89
+ { re: /(?<![\p{L}\p{N}])(TODO|TBD|XXX|FIXME)(?![\p{L}\p{N}])/giu, what: 'маркер TODO/TBD/XXX' },
90
+ ];
91
+
92
+ /** A structured marker Phase 2 OWNS. It must reach Phase 2, so it is never a finding here. */
93
+ const GAP = /\[GAP:[^\]\n]*\]/g;
94
+
95
+ const SUSPECT = /\[[^\]\n]{1,80}\](?![(\[])/g;
96
+
97
+ /**
98
+ * A markdown TASK-LIST CHECKBOX is not a placeholder.
99
+ *
100
+ * MEASURED 2026-08-27 against a real project: `- [ ] AC покрыты автотестами` and 16 siblings were
101
+ * reported as "possibly unfilled". Worse, that project's own Completion.md TEACHES the convention —
102
+ * "флажки `[ ]` при каждом FR-GROWTH-00N" — so this warning fired on the notation the pipeline
103
+ * itself prescribes. Noise on a legitimate convention trains people to ignore warnings, which is
104
+ * the failure this whole checker exists to prevent, one level down.
105
+ */
106
+ const CHECKBOX = /^\s*(?:[-*+]\s+)?\[[ xX]?\]/;
107
+
108
+ function scan(body) {
109
+ const clean = stripFences(body).replace(GAP, '');
110
+ const blocking = [];
111
+ for (const { re, what } of BLOCKING) {
112
+ re.lastIndex = 0;
113
+ for (let m = re.exec(clean); m !== null && blocking.length < 3; m = re.exec(clean)) {
114
+ blocking.push(what + ' «' + m[0].slice(0, 40) + '»');
115
+ }
116
+ }
117
+ const warn = [];
118
+ // Line-wise, so a checkbox can be recognised by its POSITION in the line — `[ ]` anywhere else
119
+ // is not a task item. A table cell holding `| [ ] |` counts too: the same convention, in a table.
120
+ for (const line of clean.split('\n')) {
121
+ if (warn.length >= 3) break;
122
+ // STRIP the checkbox, do not skip the LINE. Skipping it would be an escape hatch: a genuine
123
+ // placeholder could hide behind a checkbox prefix, which is exactly what the test found when
124
+ // the first version skipped whole lines.
125
+ const rest = line.replace(CHECKBOX, '').replace(/\|\s*\[[ xX]?\]\s*\|/g, '| |');
126
+ SUSPECT.lastIndex = 0;
127
+ for (let m = SUSPECT.exec(rest); m !== null && warn.length < 3; m = SUSPECT.exec(rest)) {
128
+ const t = m[0];
129
+ if (/^\[\^?\d+\]$/.test(t)) continue; // a citation or footnote
130
+ if (/^\[[ xX]?\]$/.test(t)) continue; // a bare checkbox mid-line
131
+ warn.push(t.slice(0, 40));
132
+ }
133
+ }
134
+ return { blocking, warn };
135
+ }
136
+
137
+ function main() {
138
+ const root = process.argv[2] || '.';
139
+ const docs = path.join(root, 'docs');
140
+ let st;
141
+ try { st = fs.statSync(docs); } catch {
142
+ cannotCheck('нет каталога ' + docs,
143
+ 'Фаза 1 ещё не отработала — это НЕ «документы неполны», это «проверять нечего»');
144
+ }
145
+ if (!st.isDirectory()) cannotCheck(docs + ' существует, но это не каталог');
146
+
147
+ const problems = [];
148
+ const warnings = [];
149
+ let checked = 0;
150
+
151
+ for (const d of DOCS) {
152
+ const abs = path.join(docs, d.file);
153
+ let body;
154
+ try { body = fs.readFileSync(abs, 'utf-8'); } catch (e) {
155
+ if (e && e.code === 'ENOENT') {
156
+ if (!d.optional) problems.push(d.file + ': отсутствует');
157
+ else if (d.expected) warnings.push(d.file + ': отсутствует, хотя конвейер его обещает');
158
+ continue; // an optional absence is a legitimate answer
159
+ }
160
+ cannotCheck('не читается ' + d.file + ': ' + ((e && e.message) || e));
161
+ }
162
+ checked++;
163
+ if (body.trim().length < MIN_CHARS) {
164
+ problems.push(d.file + ': пуст или почти пуст (' + body.trim().length + ' симв., порог '
165
+ + MIN_CHARS + ')');
166
+ continue;
167
+ }
168
+ const { blocking, warn } = scan(body);
169
+ if (blocking.length) problems.push(d.file + ': остались ' + blocking.join(', '));
170
+ if (warn.length) warnings.push(d.file + ': возможно незаполнено — ' + warn.join(', '));
171
+ }
172
+
173
+ if (!checked) {
174
+ cannotCheck('в ' + docs + ' не нашлось ни одного SPARC-документа',
175
+ 'каталог есть, но пуст — это не «всё в порядке»');
176
+ }
177
+
178
+ const sayWarnings = () => {
179
+ if (!warnings.length) return;
180
+ say('⚠️ на глаз (НЕ блокирует — скрипт не отличает шаблон от подписи к диаграмме):');
181
+ for (const w of warnings) say(' • ' + w);
182
+ };
183
+
184
+ if (problems.length) {
185
+ sayWarnings();
186
+ say('❌ документы Фазы 1 не готовы к валидации (' + problems.length + '):');
187
+ for (const p of problems) say(' • ' + p);
188
+ say(' Рой валидации запускать рано: он потратит агентов на то, что видно отсюда.');
189
+ process.exit(1);
190
+ }
191
+
192
+ sayWarnings();
193
+ say('✅ все обязательные документы на месте, непусты и без незаполненных шаблонов ('
194
+ + checked + ' проверено)');
195
+ say(' Ограничение: это доказывает, что документы НАПИСАНЫ, а не что они верны.');
196
+ say(' Верность — работа роя валидации Фазы 2; проверка лишь не даёт потратить его впустую.');
197
+ process.exit(0);
198
+ }
199
+
200
+ try { main(); } catch (err) {
201
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
202
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-growth-trace.cjs — did the M5 growth analysis survive into the Specification, or was it
6
+ * analysed and dropped?
7
+ *
8
+ * NOT an event hook. Like `state-update.cjs` and `check-ports.cjs`, it lives here because this
9
+ * directory already carries plain Node utilities; nothing registers it in settings.json. This is
10
+ * deliberate and load-bearing: this package's hooks are NON-BLOCKING by contract (pinned by
11
+ * tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
12
+ * anything — it could only print. Invoke it:
13
+ *
14
+ * node .claude/hooks/check-growth-trace.cjs [path-to-project]
15
+ *
16
+ * Exit codes — three, and the third is the point:
17
+ * 0 every seed row is traced into docs/Specification.md, or rejected on the record
18
+ * 1 the seed table carries rows and the Specification traces none of them
19
+ * 2 THE CHECK DID NOT RUN — no brief, no Specification, or a seed table that would not parse
20
+ *
21
+ * A checker that answers "clean" when it could not look converts an unknown into a reassurance.
22
+ * An ABSENT brief means Phase 0 never ran (the --from-docs entry skips it); that is exit 2, never 0
23
+ * and never 1. "Phase 0 did not run" is not "nothing is missing".
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const BRIEF = path.join('docs', 'product-discovery-brief.md');
30
+ const SPEC = path.join('docs', 'Specification.md');
31
+
32
+ /** The exact token, case-sensitive. Not a title, not a paraphrase — the same definition the
33
+ * validator's prose gate uses, so the two cannot disagree about what a mention is. */
34
+ const ID = /\bFR-GROWTH-(\d{3})\b/g;
35
+
36
+ /** A line that refuses an obligation. Shared by mentioned() and rejected() so the two rules cannot
37
+ * disagree about what a refusal looks like. */
38
+ const REJECT_WORD = /(отклон\w*|не берём|не беремся|не берем|rejected|declined|out of scope|вне области)/i;
39
+
40
+ function say(s) { process.stdout.write(s + '\n'); }
41
+
42
+ /** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
43
+ function cannotCheck(reason, hint) {
44
+ say('⚠️ проверка НЕ выполнена: ' + reason);
45
+ if (hint) say(' ' + hint);
46
+ process.exit(2);
47
+ }
48
+
49
+ /**
50
+ * Read one required file. Asks about the EXACT path — never lists a directory and matches names
51
+ * against the listing, because a listing answers a different question than "does this file exist"
52
+ * and the two diverge on case, symlinks and unicode normalisation.
53
+ */
54
+ function readRequired(root, rel, absentReason, hint) {
55
+ const abs = path.join(root, rel);
56
+ let st;
57
+ try { st = fs.statSync(abs); } catch { cannotCheck(absentReason, hint); }
58
+ if (!st.isFile()) cannotCheck(rel + ' существует, но это не файл');
59
+ try { return fs.readFileSync(abs, 'utf-8'); } catch (e) {
60
+ cannotCheck('не читается ' + rel + ': ' + ((e && e.message) || e));
61
+ }
62
+ return '';
63
+ }
64
+
65
+ /**
66
+ * The seed rows, as the brief records them.
67
+ *
68
+ * A row is a markdown table row whose FIRST cell is an id. The template ships an example row with
69
+ * a placeholder id inside the module, so a row whose requirement cell is still a bracketed
70
+ * placeholder is a TEMPLATE row, not a real obligation, and counting it would let an untouched
71
+ * template look like a filled-in one.
72
+ */
73
+ function seedRows(brief) {
74
+ const rows = [];
75
+ for (const raw of brief.split('\n')) {
76
+ const line = raw.trim();
77
+ if (!line.startsWith('|')) continue;
78
+ const cells = line.split('|').map((c) => c.trim());
79
+ // cells[0] is '' for a leading pipe; the id lives in cells[1]
80
+ const m = /^FR-GROWTH-(\d{3})$/.exec(cells[1] || '');
81
+ if (!m) continue;
82
+ const requirement = cells[2] || '';
83
+ const isPlaceholder = /^\[.*\]$/.test(requirement) || requirement === '...' || requirement === '';
84
+ if (isPlaceholder) continue;
85
+ const status = (cells[5] || cells[4] || '').toUpperCase();
86
+ rows.push({ id: cells[1], speculative: status.includes('SPECULATIVE') });
87
+ }
88
+ return rows;
89
+ }
90
+
91
+ /**
92
+ * Ids the Specification mentions, by the same definition the validator's prose gate uses.
93
+ *
94
+ * A REJECTION LINE IS NOT A MENTION. The two rules overlap on exactly the case that matters: a line
95
+ * reading `FR-GROWTH-001 rejected` contains the exact token, so a naive mention rule reports the
96
+ * obligation as carried forward — and the reason requirement on the rejection path is never reached.
97
+ * MEASURED before this fix: that line exited 0. Cross-family review found the reason-check hole; the
98
+ * hole was one layer deeper, in which of the two rules got to answer first.
99
+ */
100
+ function mentioned(spec) {
101
+ const out = new Set();
102
+ for (const line of spec.split('\n')) {
103
+ if (REJECT_WORD.test(line)) continue; // a refusal is decided by rejected(), which wants a reason
104
+ ID.lastIndex = 0;
105
+ for (let m = ID.exec(line); m !== null; m = ID.exec(line)) out.add(m[0]);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * A row may also be REJECTED on the record instead of traced — the validator's prose gate says the
112
+ * same. A rejection is a line naming the id together with a rejection word AND a reason marker,
113
+ * because "FR-GROWTH-004 не берём" with nothing after it is indistinguishable from forgetting.
114
+ */
115
+ function rejected(brief, spec, id) {
116
+ const re = new RegExp('^.*\\b' + id + '\\b.*$', 'gm');
117
+ for (const hay of [brief, spec]) {
118
+ for (const line of hay.match(re) || []) {
119
+ const m = REJECT_WORD.exec(line);
120
+ if (!m) continue;
121
+ // The reason must live AFTER the rejection word. Scanning the whole line was a false-clean:
122
+ // cross-family review found that `FR-GROWTH-001 rejected` passed, because the reason pattern
123
+ // included a bare hyphen and the IDENTIFIER contains two of them. MEASURED before the fix —
124
+ // that exact line exited 0. So: look only at the tail, and never at punctuation alone.
125
+ const tail = line.slice(m.index + m[0].length);
126
+ // A reason is WORDS, not a dash. A separator may introduce it but can never be it.
127
+ const hasReason = /[\p{L}\p{N}][\p{L}\p{N}\s]{6,}/u.test(tail.replace(/^[\s:—–-]+/, ''));
128
+ if (hasReason) return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ function main() {
135
+ const root = process.argv[2] || '.';
136
+ try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
137
+ catch { cannotCheck('путь не существует: ' + root); }
138
+
139
+ const brief = readRequired(root, BRIEF,
140
+ 'нет файла ' + BRIEF,
141
+ 'это значит, что Фаза 0 не запускалась (вход --from-docs её пропускает) — а НЕ что требований по росту не нужно');
142
+
143
+ const rows = seedRows(brief);
144
+
145
+ // A REUSED id makes the brief malformed, and malformed is exit 2 — never a pass. The module's own
146
+ // rule is that a number is never reused; when it is, two distinct obligations share one token and
147
+ // a SINGLE mention in the Specification marks BOTH traced. Cross-family review found this, and it
148
+ // is the recurring shape: coverage counted over usable ITEMS instead of per POSITION.
149
+ const dupes = [...new Set(rows.map((r) => r.id).filter((id, i, a) => a.indexOf(id) !== i))];
150
+ if (dupes.length) {
151
+ cannotCheck('в таблице-семени повторяются идентификаторы: ' + dupes.join(', '),
152
+ 'номер FR-GROWTH-nnn не переиспользуется — пока дубли не разведены, одно упоминание в '
153
+ + 'Specification.md зачло бы сразу два разных требования');
154
+ }
155
+
156
+ if (!rows.length) {
157
+ // An empty seed is a legitimate answer ("нет"), but it is not this checker's business: there is
158
+ // nothing to trace. Saying "clean" here would claim a check that did not happen.
159
+ cannotCheck('в брифе нет ни одной заполненной строки FR-GROWTH-nnn',
160
+ 'либо M5 не запускался, либо таблица-семя осталась шаблоном — проверять нечего');
161
+ }
162
+
163
+ const spec = readRequired(root, SPEC, 'нет файла ' + SPEC,
164
+ 'без спецификации не с чем сверять — это не «всё прослежено»');
165
+
166
+ const seen = mentioned(spec);
167
+ const missing = rows.filter((r) => !seen.has(r.id) && !rejected(brief, spec, r.id));
168
+
169
+ if (missing.length === rows.length) {
170
+ say('❌ ни одно требование по росту не доехало до ' + SPEC + ':');
171
+ for (const r of missing) say(' • ' + r.id + (r.speculative ? ' (SPECULATIVE)' : ''));
172
+ say(' Разбор роста сделан и выброшен — это ровно тот класс потерь, который ловит проверка.');
173
+ process.exit(1);
174
+ }
175
+ if (missing.length) {
176
+ say('❌ часть требований по росту потеряна (' + missing.length + ' из ' + rows.length + '):');
177
+ for (const r of missing) say(' • ' + r.id + (r.speculative ? ' (SPECULATIVE)' : ''));
178
+ say(' Каждое надо либо перенести в ' + SPEC + ', либо отклонить С ПРИЧИНОЙ — молча уронить нельзя.');
179
+ process.exit(1);
180
+ }
181
+ say('✅ все ' + rows.length + ' требований по росту прослежены в ' + SPEC + ' либо отклонены с причиной');
182
+ say(' Ограничение: это доказывает, что обязательство ДОНЕСЛИ, а не что его построили.');
183
+ process.exit(0);
184
+ }
185
+
186
+ try {
187
+ main();
188
+ } catch (err) {
189
+ // Even an unexpected failure must not read as "clean".
190
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
191
+ }
@@ -59,8 +59,19 @@ function cannotCheck(reason, hint) {
59
59
  process.exit(2);
60
60
  }
61
61
 
62
+ /**
63
+ * Absolutise ONCE, at the boundary.
64
+ *
65
+ * The invariant this restores: one frame of reference per path. A relative `-f` handed to a
66
+ * subprocess whose cwd we also override is resolved TWICE against two different origins, and the
67
+ * directory component appears twice. Keeping the argument relative past this point is what made
68
+ * `check-ports.cjs projects/01` report `.../projects/01/projects/01/docker-compose.yml`.
69
+ *
70
+ * Absolute from here on means the existence checks, the `-f` argument and the printed cure all name
71
+ * the same object — so the cure REPRODUCES the failure instead of refuting it.
72
+ */
62
73
  function resolveCompose(arg) {
63
- const target = arg || '.';
74
+ const target = path.resolve(process.cwd(), arg || '.');
64
75
  let file = target;
65
76
  try {
66
77
  if (fs.statSync(target).isDirectory()) file = path.join(target, 'docker-compose.yml');
@@ -74,16 +85,37 @@ function resolveCompose(arg) {
74
85
  /** The normalised config. Parsing the raw YAML would re-implement `extends`, interpolation and the
75
86
  * short `"5432:5432"` form — and the short form is exactly where a hand parser gets host_ip wrong. */
76
87
  function normalisedConfig(file) {
77
- const r = spawnSync('docker', ['compose', '-f', file, 'config'],
78
- { encoding: 'utf8', cwd: path.dirname(path.resolve(file)) });
88
+ // NO cwd override deliberately, and the deletion is the fix rather than a tidy-up.
89
+ //
90
+ // It used to be `cwd: path.dirname(path.resolve(file))`, which is how the doubling happened: `-f`
91
+ // was relative and got re-resolved against the cwd this very option installed. Absolutising `file`
92
+ // alone would have made the line harmless while leaving the false premise that compose needs its
93
+ // cwd set — and the next relative path added here would reopen the class.
94
+ //
95
+ // MEASURED (Compose v5.1.1), same absolute -f from two different cwds, byte-identical output:
96
+ // project name -> from the file's directory, not cwd
97
+ // build: ./app -> context resolved under the file's directory
98
+ // .env discovery -> the project-dir .env won; the cwd's .env was NOT even a fallback
99
+ // env_file: ./x.env -> compose still demanded the project-dir copy
100
+ // All three candidate justifications are project-directory-derived, and the project directory
101
+ // comes from the -f path. Scoped honestly: this is Compose v2+ semantics; v1 differed.
102
+ const r = spawnSync('docker', ['compose', '-f', file, 'config'], { encoding: 'utf8' });
79
103
  if (r.error && r.error.code === 'ENOENT') {
80
104
  cannotCheck('docker недоступен на этой машине',
81
105
  'без него нормализованный конфиг получить нечем, а разбирать YAML руками — значит ошибиться на короткой форме портов');
82
106
  }
83
107
  if (r.status !== 0) {
108
+ // Report, do not guess. The old hint said "обычно это незаданная переменная" — a cause that
109
+ // CANNOT produce this exit: a plain unset ${VAR} makes `docker compose config` exit 0 with a
110
+ // warning; only the required form ${VAR:?msg} exits 1. It named a subset of an already-narrow
111
+ // class while the actual cause was this checker's own invocation.
112
+ //
113
+ // And the cure now carries the ABSOLUTE path actually passed. It used to print the relative form
114
+ // without the cwd override — i.e. the invocation that SUCCEEDS — so the tool handed the user a
115
+ // reproducer that refuted it.
84
116
  const why = String(r.stderr || '').trim().split('\n')[0] || 'причина неизвестна';
85
117
  cannotCheck('docker compose config вернул ошибку: ' + why,
86
- 'обычно это незаданная переменная; посмотреть: docker compose -f ' + file + ' config');
118
+ 'повторить ровно то, что делали мы: docker compose -f ' + file + ' config');
87
119
  }
88
120
  return String(r.stdout || '');
89
121
  }