@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.
- package/.dz-manifest.json +92 -32
- package/CHANGELOG.md +176 -0
- package/README.md +106 -4
- package/package.json +4 -4
- package/sbom.json +181 -31
- package/src/commands/doctor.js +43 -31
- package/src/commands/verify.js +27 -2
- package/src/utils.js +27 -0
- package/templates/.claude/commands/myinsights.md +22 -5
- package/templates/.claude/commands/replicate.md +57 -1
- package/templates/.claude/hooks/check-docs-complete.cjs +202 -0
- package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
- package/templates/.claude/hooks/check-ports.cjs +36 -4
- package/templates/.claude/hooks/statusline.cjs +16 -5
- package/templates/.claude/rules/replicate-pipeline.md +14 -4
- package/templates/.claude/rules/skill-interface-protocol.md +9 -0
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-code.sh +40 -7
- package/templates/.claude/skills/brutal-honesty-review/scripts/assess-tests.sh +38 -11
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-hooks-commands.md +40 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +2 -2
- package/templates/.claude/skills/requirements-validator/SKILL.md +52 -0
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +9 -9
- package/tests/snapshot/baseline.json +25 -23
- package/tests/unit/absence-is-not-emptiness.test.js +255 -0
- package/tests/unit/assess-scripts.test.js +150 -0
- package/tests/unit/check-docs-complete.test.js +292 -0
- package/tests/unit/check-growth-trace.test.js +188 -0
- package/tests/unit/check-ports.test.js +99 -0
- package/tests/unit/generated-guard-templates.test.js +134 -0
- package/tests/unit/growth-axes-and-compliance.test.js +169 -0
- package/tests/unit/growth-gate-conditional.test.js +122 -0
- package/tests/unit/growth-module-b2b-gate.test.js +20 -2
- package/tests/unit/growth-requirements-bridge.test.js +127 -0
- package/tests/unit/guard-forms.test.js +302 -0
- package/tests/unit/insights-docs-tell-the-truth.test.js +84 -0
- package/tests/unit/module-copy-identity.test.js +106 -0
- package/tests/unit/shipped-suite-context.test.js +142 -0
- package/tests/unit/skill-paths-prebaked.test.js +174 -0
- 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).
|
|
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.
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
'
|
|
118
|
+
'повторить ровно то, что делали мы: docker compose -f ' + file + ' config');
|
|
87
119
|
}
|
|
88
120
|
return String(r.stdout || '');
|
|
89
121
|
}
|