@dzhechkov/p-replicator 1.12.0 → 1.13.1
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 +225 -61
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +132 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +470 -60
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +32 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +29 -5
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +27 -6
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +121 -52
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
- package/tests/snapshot/baseline.json +60 -38
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-ports.test.js +46 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +91 -9
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/utils.test.js +40 -3
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* check-canon.cjs — перед ПИШУЩИМ фан-аутом канон зафиксирован, и он ещё цел?
|
|
6
|
+
*
|
|
7
|
+
* NOT an event hook. Like `check-ports.cjs`, `check-look-trace.cjs`, `check-growth-trace.cjs`,
|
|
8
|
+
* `check-docs-complete.cjs`, `check-swarm-receipts.cjs`, `check-embed-contract.cjs`,
|
|
9
|
+
* `check-webhook-contract.cjs`, `check-job-contract.cjs` and `check-model-cost.cjs`, it lives here
|
|
10
|
+
* because this directory already carries plain Node utilities; nothing registers it in
|
|
11
|
+
* settings.json. That is deliberate: this package's hooks are NON-BLOCKING by contract (pinned by
|
|
12
|
+
* tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
|
|
13
|
+
* anything — it could only print. Invoke it:
|
|
14
|
+
*
|
|
15
|
+
* node .claude/hooks/check-canon.cjs [path-to-project]
|
|
16
|
+
*
|
|
17
|
+
* WHY IT EXISTS — the failure, before the technology.
|
|
18
|
+
*
|
|
19
|
+
* N parallel workers derive names from ONE source document. Each worker is internally consistent:
|
|
20
|
+
* it reads the source, picks a model name, an enum value, a route path, a step number, and writes
|
|
21
|
+
* code that agrees with itself. The COLLISION does not exist inside any worker — it exists only in
|
|
22
|
+
* the UNION of their outputs. So no worker can see it, and no delivery receipt can catch it: every
|
|
23
|
+
* receipt is a truthful report about a file that is individually fine.
|
|
24
|
+
*
|
|
25
|
+
* THE SCOPE IS NARROW ON PURPOSE, and the narrowing is the whole reason this check is legitimate.
|
|
26
|
+
* It applies to a fan-out that WRITES — `/start` Phase 2 (one independent Task per package, each
|
|
27
|
+
* told to generate `src/`, `tests/`, routes and an ORM schema) and `/feature` Phase 3 (implement +
|
|
28
|
+
* write tests + commit). It does NOT apply to `/replicate`, whose fan-out is read-only validators:
|
|
29
|
+
* a checker family proposed for "freeze the canon before every fan-out" was refused twice, and
|
|
30
|
+
* refused CORRECTLY, because read-only reviewers cannot collide. Do not widen this.
|
|
31
|
+
*
|
|
32
|
+
* MEASURED — this repository, 2026-09-01, three swarms on one package with no canon pinned:
|
|
33
|
+
* · two DIFFERENT steps carried the number «Шаг 2.2», on the same line of two worktrees;
|
|
34
|
+
* · two adjacent lines both signed «All 13 rules», one listing 12 names and the other 11, with
|
|
35
|
+
* 13 on disk — a list that split in half and lied on both halves;
|
|
36
|
+
* · a budget marker was moved to three different values by three swarms;
|
|
37
|
+
* · 23 merge conflicts in the second branch, 17 in the first, and 6 test failures on the merged
|
|
38
|
+
* tree from suites that were green individually.
|
|
39
|
+
*
|
|
40
|
+
* THE ASYMMETRY THAT DECIDES THE CURE, same package, same merge, same day: the hook counter (17)
|
|
41
|
+
* is pinned by a UNIT TEST and survived the merge intact; the rule counter (13) lived in PROSE and
|
|
42
|
+
* split in two. One repository, one cause, two outcomes. That is why the cure here is a check and
|
|
43
|
+
* not another paragraph — a counter with a layer-1 guard held, a counter guarded by reading did not.
|
|
44
|
+
*
|
|
45
|
+
* WHAT THIS FILE CAN AND CANNOT DECIDE — read before trusting exit 0.
|
|
46
|
+
*
|
|
47
|
+
* It reads a DECLARATION, `docs/dispatch-plan.md`, plus the canon file that declaration points at,
|
|
48
|
+
* and it settles exactly three things:
|
|
49
|
+
*
|
|
50
|
+
* 1. the canon is PINNED — named, readable, and its live sha256 equals the recorded one, so the
|
|
51
|
+
* document the workers were sent to has not moved under them;
|
|
52
|
+
* 2. inside the canon, no two SIBLING headings carry the same ordinal — an ordinal that repeats
|
|
53
|
+
* under one parent cannot address anything, which is the «Шаг 2.2» defect exactly;
|
|
54
|
+
* 3. inside the canon, an enumeration that CLAIMS a count delivers that count, and two claims
|
|
55
|
+
* about the same population do not disagree — the «All 13 rules» defect exactly.
|
|
56
|
+
*
|
|
57
|
+
* It does NOT decide COMPLETENESS. A matching hash proves the canon did not change; it says nothing
|
|
58
|
+
* about whether the canon enumerated every shared choice the workers will have to make. That half
|
|
59
|
+
* stays layer 3 (a coordinator's judgement recorded at the seam) and this file says so out loud
|
|
60
|
+
* rather than letting a green exit imply it.
|
|
61
|
+
*
|
|
62
|
+
* THE EXACT FORM OF `docs/dispatch-plan.md` — kept here on purpose: this file is not part of the
|
|
63
|
+
* always-loaded corpus, so the long form costs nothing per run.
|
|
64
|
+
*
|
|
65
|
+
* **Пишущий фан-аут:** да (да | нет — `нет` is a legitimate answer)
|
|
66
|
+
* **Канон:** docs/canon.md (path, relative to the project root)
|
|
67
|
+
* **Хеш канона:** <64 hex> (sha256 of that file at the moment it was frozen)
|
|
68
|
+
* **Проверка канона:** ВЫПОЛНЕНА (ВЫПОЛНЕНА | НЕ ВЫПОЛНЕНА)
|
|
69
|
+
* **Причина:** — (required when НЕ ВЫПОЛНЕНА; one of the closed REASONS)
|
|
70
|
+
*
|
|
71
|
+
* ## Единицы
|
|
72
|
+
*
|
|
73
|
+
* | Единица | Что пишет |
|
|
74
|
+
* |---|---|
|
|
75
|
+
* | packages/api | src/api/**, tests/api/** |
|
|
76
|
+
* | packages/web | src/web/**, tests/web/** |
|
|
77
|
+
*
|
|
78
|
+
* Exit codes — three, and the third is the point:
|
|
79
|
+
* 0 two or more writing units, a canon that is pinned and still matches, no ordinal collision,
|
|
80
|
+
* no contradicted count
|
|
81
|
+
* 1 a defect is PROVEN and named: a writing fan-out with no canon named, a canon path that does
|
|
82
|
+
* not exist, a recorded hash that no longer matches the file, two sibling headings under one
|
|
83
|
+
* ordinal, a count claim contradicted by its own list, or two claims about one population
|
|
84
|
+
* that disagree
|
|
85
|
+
* 2 THE CHECK DID NOT RUN — no plan, an unrecognised value, a malformed hash, duplicate unit
|
|
86
|
+
* rows, or the legitimate answers «пишущего фан-аута нет», «единица одна» (a sequential run
|
|
87
|
+
* cannot collide in a union of one) and «проверка НЕ ВЫПОЛНЕНА, причина такая-то»
|
|
88
|
+
*
|
|
89
|
+
* A checker that answers "clean" when it could not look converts an unknown into a reassurance.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
const fs = require('node:fs');
|
|
93
|
+
const path = require('node:path');
|
|
94
|
+
const crypto = require('node:crypto');
|
|
95
|
+
|
|
96
|
+
const PLAN = path.join('docs', 'dispatch-plan.md');
|
|
97
|
+
|
|
98
|
+
/** Does this dispatch WRITE? A CLOSED set. `нет` is legitimate and exits 2: a read-only fan-out
|
|
99
|
+
* (the `/replicate` validators) cannot produce a collision, so there is nothing here to pin. */
|
|
100
|
+
const WRITES = { 'ДА': true, 'НЕТ': false };
|
|
101
|
+
|
|
102
|
+
/** Was the canon actually frozen? CLOSED, and the negative answer is honest rather than a failure —
|
|
103
|
+
* `honest-configuration` CFG-I4: an unreachable truth yields UNKNOWN, never a plausible value. */
|
|
104
|
+
const RUN_STATUS = { 'ВЫПОЛНЕНА': 'done', 'НЕ ВЫПОЛНЕНА': 'not-done' };
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why the canon was not frozen. CLOSED list — free text is not a reason here, because the whole
|
|
108
|
+
* value of the list is that each entry names a DIFFERENT repair:
|
|
109
|
+
* канон-не-собран — assemble the shared names, then re-check
|
|
110
|
+
* источник-недоступен — get the source document, then pin it
|
|
111
|
+
* решение-отложено — take the decision (this is the one that quietly becomes a merge conflict)
|
|
112
|
+
* вне-объёма — decide it is out of scope and record the decision
|
|
113
|
+
*/
|
|
114
|
+
const REASONS = ['канон-не-собран', 'источник-недоступен', 'решение-отложено', 'вне-объёма'];
|
|
115
|
+
|
|
116
|
+
const HEX64 = /^[0-9a-f]{64}$/i;
|
|
117
|
+
|
|
118
|
+
function say(s) { process.stdout.write(s + '\n'); }
|
|
119
|
+
|
|
120
|
+
/** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
|
|
121
|
+
function cannotCheck(reason, hint) {
|
|
122
|
+
say('⚠️ проверка НЕ выполнена: ' + reason);
|
|
123
|
+
if (hint) say(' ' + hint);
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Exit 1 with the defect NAMED. A violation that cannot be named is a 2, not a 1. */
|
|
128
|
+
function proven(title, lines, tail) {
|
|
129
|
+
say('❌ ' + title);
|
|
130
|
+
for (const line of lines) say(' • ' + line);
|
|
131
|
+
if (tail) say(' ' + tail);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The value of a `**Label:** value` header line, or null when the label is absent entirely.
|
|
137
|
+
* An EMPTY value is returned as '' and is never collapsed into "absent" — those are different
|
|
138
|
+
* mistakes with different repairs (`honest-configuration` CFG-I2).
|
|
139
|
+
*/
|
|
140
|
+
function header(text, label) {
|
|
141
|
+
const re = new RegExp('^\\s*\\*\\*' + label + ':?\\*\\*\\s*:?(.*)$', 'im');
|
|
142
|
+
const m = re.exec(text);
|
|
143
|
+
if (!m) return null;
|
|
144
|
+
return m[1].trim().replace(/^[«"`]|[»"`]$/g, '').trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A header value read against a CLOSED map, with both failure modes kept apart. */
|
|
148
|
+
function closedHeader(text, label, map, what) {
|
|
149
|
+
const raw = header(text, label);
|
|
150
|
+
if (raw === null) {
|
|
151
|
+
cannotCheck('в плане нет строки `**' + label + ':**`',
|
|
152
|
+
what + ' — допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
153
|
+
}
|
|
154
|
+
const key = raw.toUpperCase().replace(/\s+/g, ' ').trim();
|
|
155
|
+
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
|
156
|
+
cannotCheck('нераспознанное значение `' + label + '`: ' + (key === '' ? '(пусто)' : key),
|
|
157
|
+
'допустимы ровно: ' + Object.keys(map).join(' | '));
|
|
158
|
+
}
|
|
159
|
+
return map[key];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The unit table, as the plan records it.
|
|
164
|
+
*
|
|
165
|
+
* A row is a markdown table row with at least two cells whose FIRST cell is neither the header word
|
|
166
|
+
* nor a `|---|` separator — so the header row cannot be mistaken for data, and a unit that names no
|
|
167
|
+
* written path is reported rather than silently dropped.
|
|
168
|
+
*/
|
|
169
|
+
function unitRows(text) {
|
|
170
|
+
const rows = [];
|
|
171
|
+
for (const raw of text.split('\n')) {
|
|
172
|
+
const line = raw.trim();
|
|
173
|
+
if (!line.startsWith('|')) continue;
|
|
174
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
175
|
+
if (cells.length < 4) continue; // '' + 2 columns + ''
|
|
176
|
+
const name = cells[1];
|
|
177
|
+
if (!name || /^:?-+:?$/.test(name)) continue;
|
|
178
|
+
if (name.toLowerCase() === 'единица') continue;
|
|
179
|
+
rows.push({ name, writes: cells[2] });
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// The canon's own two defects. Both are read STRUCTURALLY, never by a global regular expression:
|
|
186
|
+
// a global pattern answers "does this string appear twice in the file", which is a DIFFERENT
|
|
187
|
+
// question from the one that matters — "can a reader address this number / does this list keep its
|
|
188
|
+
// own promise" — and answering the wrong question is the very defect class this family exists for.
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Headings of a markdown document, each with its LEVEL, its ordinal (if any) and its body.
|
|
193
|
+
*
|
|
194
|
+
* The body is everything until the next heading of the same or lower level, so «two headings, same
|
|
195
|
+
* ordinal, different bodies» can be decided by comparison rather than by guessing.
|
|
196
|
+
*
|
|
197
|
+
* Fenced code blocks are skipped: a `#` inside a shell example is a comment, not a heading, and a
|
|
198
|
+
* parser that cannot tell them apart invents siblings that do not exist.
|
|
199
|
+
*/
|
|
200
|
+
function headings(text) {
|
|
201
|
+
const lines = text.split('\n');
|
|
202
|
+
const found = [];
|
|
203
|
+
let fenced = false;
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const line = lines[i];
|
|
206
|
+
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; continue; }
|
|
207
|
+
if (fenced) continue;
|
|
208
|
+
const m = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
found.push({ level: m[1].length, title: m[2], line: i + 1, start: i });
|
|
211
|
+
}
|
|
212
|
+
for (let i = 0; i < found.length; i++) {
|
|
213
|
+
let end = lines.length;
|
|
214
|
+
for (let j = i + 1; j < found.length; j++) {
|
|
215
|
+
if (found[j].level <= found[i].level) { end = found[j].start; break; }
|
|
216
|
+
}
|
|
217
|
+
found[i].body = lines.slice(found[i].start + 1, end).join('\n').trim();
|
|
218
|
+
}
|
|
219
|
+
return found;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The ordinal a heading claims, or null.
|
|
224
|
+
*
|
|
225
|
+
* Only a DOTTED numeral counts — `2.2`, `10.3.1`. A bare `2` is deliberately excluded: `## 2 файла`
|
|
226
|
+
* and `### Шаг 2` are ordinary prose in these documents, and firing on them would make the check
|
|
227
|
+
* eager, which is how a guard becomes a deleted guard. The measured defect was a dotted step
|
|
228
|
+
* number, and that is what is claimed here.
|
|
229
|
+
*/
|
|
230
|
+
function ordinalOf(title) {
|
|
231
|
+
const m = /(?:^|[\s(«"`])(\d+(?:\.\d+)+)(?=[\s.:—–\-)»"`]|$)/.exec(title);
|
|
232
|
+
return m ? m[1] : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The nearest preceding heading of a LOWER level — the parent whose scope the ordinal lives in. */
|
|
236
|
+
function parentKey(list, index) {
|
|
237
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
238
|
+
if (list[j].level < list[index].level) return list[j].line + ':' + list[j].title;
|
|
239
|
+
}
|
|
240
|
+
return '(корень документа)';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sibling headings that claim the same ordinal.
|
|
245
|
+
*
|
|
246
|
+
* SCOPE IS THE POINT. `Шаг 2.2` under «Фаза A» and `Шаг 2.2` under «Фаза B» are two different,
|
|
247
|
+
* perfectly addressable steps; the same two under ONE parent are a number that addresses nothing.
|
|
248
|
+
* A global search cannot tell those apart and would refuse the legitimate document.
|
|
249
|
+
*/
|
|
250
|
+
function ordinalCollisions(text) {
|
|
251
|
+
const list = headings(text);
|
|
252
|
+
const buckets = new Map();
|
|
253
|
+
for (let i = 0; i < list.length; i++) {
|
|
254
|
+
const ord = ordinalOf(list[i].title);
|
|
255
|
+
if (!ord) continue;
|
|
256
|
+
const key = parentKey(list, i) + ' ⟶ ' + list[i].level + ' ⟶ ' + ord;
|
|
257
|
+
if (!buckets.has(key)) buckets.set(key, []);
|
|
258
|
+
buckets.get(key).push(list[i]);
|
|
259
|
+
}
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const group of buckets.values()) {
|
|
262
|
+
if (group.length < 2) continue;
|
|
263
|
+
const bodiesDiffer = new Set(group.map((h) => h.body)).size > 1;
|
|
264
|
+
out.push({
|
|
265
|
+
ordinal: ordinalOf(group[0].title),
|
|
266
|
+
bodiesDiffer,
|
|
267
|
+
where: group.map((h) => 'строка ' + h.line + ': «' + h.title + '»'),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Enumerations that state their own size: `- All 13 rules: \`a\`, \`b\`, …`.
|
|
275
|
+
*
|
|
276
|
+
* The list is parsed as a LIST — comma-separated items after the colon — rather than counted by a
|
|
277
|
+
* pattern over the whole line. `All 10 skills in .claude/skills/` has no colon-introduced list and
|
|
278
|
+
* is therefore not an enumeration at all: it claims a number without enumerating, which is a
|
|
279
|
+
* different (and unfalsifiable-here) thing, so it is left alone instead of being guessed at.
|
|
280
|
+
*/
|
|
281
|
+
function countClaims(text) {
|
|
282
|
+
const claims = [];
|
|
283
|
+
const lines = text.split('\n');
|
|
284
|
+
for (let i = 0; i < lines.length; i++) {
|
|
285
|
+
const m = /(?:^|[\s*_>-])(?:All|Все|Всего)\s+(\d+)\s+([^:]{1,40}?):\s*(\S.*)$/.exec(lines[i]);
|
|
286
|
+
if (!m) continue;
|
|
287
|
+
const items = m[3].split(',').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
288
|
+
if (items.length === 0) continue;
|
|
289
|
+
claims.push({
|
|
290
|
+
line: i + 1,
|
|
291
|
+
declared: Number(m[1]),
|
|
292
|
+
label: m[2].trim().toLowerCase().replace(/\s+/g, ' '),
|
|
293
|
+
items,
|
|
294
|
+
counted: items.length,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return claims;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A claim whose own list contradicts its number. */
|
|
301
|
+
function brokenCounts(claims) {
|
|
302
|
+
return claims.filter((c) => c.declared !== c.counted);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Two claims about the SAME population that disagree about its members.
|
|
307
|
+
*
|
|
308
|
+
* This is the predicate that survives when the counts happen to be right. The measured defect had
|
|
309
|
+
* two lines both signed «All 13 rules» with different lists; had both lists carried 13 names, the
|
|
310
|
+
* count check would have passed and the document would still have contradicted itself. Split
|
|
311
|
+
* knowledge is the failure — not arithmetic.
|
|
312
|
+
*/
|
|
313
|
+
function contradictedClaims(claims) {
|
|
314
|
+
const byLabel = new Map();
|
|
315
|
+
for (const c of claims) {
|
|
316
|
+
if (!byLabel.has(c.label)) byLabel.set(c.label, []);
|
|
317
|
+
byLabel.get(c.label).push(c);
|
|
318
|
+
}
|
|
319
|
+
const out = [];
|
|
320
|
+
for (const [label, group] of byLabel) {
|
|
321
|
+
if (group.length < 2) continue;
|
|
322
|
+
const norm = (c) => c.items.map((s) => s.replace(/[`'"*]/g, '').trim().toLowerCase()).sort().join('|');
|
|
323
|
+
if (new Set(group.map(norm)).size > 1) {
|
|
324
|
+
out.push({ label, where: group.map((c) => 'строка ' + c.line + ': ' + c.counted + ' имён') });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function main() {
|
|
331
|
+
const root = process.argv[2] || '.';
|
|
332
|
+
try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
|
|
333
|
+
catch { cannotCheck('путь не существует: ' + root); }
|
|
334
|
+
|
|
335
|
+
const abs = path.join(root, PLAN);
|
|
336
|
+
let text;
|
|
337
|
+
try {
|
|
338
|
+
if (!fs.statSync(abs).isFile()) cannotCheck(PLAN + ' существует, но это не файл');
|
|
339
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
340
|
+
} catch (e) {
|
|
341
|
+
if (e && e.code === 'ENOENT') {
|
|
342
|
+
cannotCheck('нет файла ' + PLAN,
|
|
343
|
+
'это значит, что вопрос о каноне НЕ ЗАДАВАЛСЯ — а НЕ что фан-аута нет; запуск без '
|
|
344
|
+
+ 'параллельных пишущих единиц отвечает `**Пишущий фан-аут:** нет`, и это законный ответ');
|
|
345
|
+
}
|
|
346
|
+
cannotCheck('не читается ' + PLAN + ': ' + ((e && e.message) || e));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 1. Does this dispatch write at all? A read-only fan-out cannot collide → 2, never 0.
|
|
350
|
+
const writes = closedHeader(text, 'Пишущий фан-аут', WRITES,
|
|
351
|
+
'без этой строки «фан-аут только читает» неотличимо от «про запись не подумали»');
|
|
352
|
+
if (!writes) {
|
|
353
|
+
cannotCheck('план говорит «Пишущий фан-аут: нет» — параллельные единицы ничего не пишут',
|
|
354
|
+
'это законный ответ, а не нарушение: столкновение имён существует только в ОБЪЕДИНЕНИИ '
|
|
355
|
+
+ 'записей, а у читающих проверяющих объединения записей нет. Фиксировать нечего.');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 2. Was the canon frozen? A named refusal is honest and exits 2.
|
|
359
|
+
const run = closedHeader(text, 'Проверка канона', RUN_STATUS,
|
|
360
|
+
'без этой строки «не фиксировали» неотличимо от «зафиксировали»');
|
|
361
|
+
if (run === 'not-done') {
|
|
362
|
+
const raw = header(text, 'Причина');
|
|
363
|
+
if (raw === null || raw === '') {
|
|
364
|
+
cannotCheck('фиксация канона НЕ ВЫПОЛНЕНА без строки `**Причина:**`',
|
|
365
|
+
'причина обязательна и берётся из закрытого списка: ' + REASONS.join(' | ')
|
|
366
|
+
+ ' — каждая означает СВОЙ ремонт');
|
|
367
|
+
}
|
|
368
|
+
const picked = REASONS.filter((r) => raw.includes(r));
|
|
369
|
+
if (picked.length !== 1) {
|
|
370
|
+
cannotCheck('причина «' + raw + '» не из закрытого списка (или названо сразу несколько)',
|
|
371
|
+
'допустимы ровно: ' + REASONS.join(' | '));
|
|
372
|
+
}
|
|
373
|
+
cannotCheck('фиксация канона НЕ ВЫПОЛНЕНА, причина: ' + picked[0],
|
|
374
|
+
'честное «неизвестно», а не «канон зафиксирован»; пока причина не закрыта, каждая '
|
|
375
|
+
+ 'параллельная единица изобретает имена заново, и увидит это только слияние');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// 3. The units. One unit is a sequential run: a union of one has nothing to collide with.
|
|
379
|
+
const rows = unitRows(text);
|
|
380
|
+
if (rows.length === 0) {
|
|
381
|
+
proven('объявлен пишущий фан-аут, но ни одна единица не названа',
|
|
382
|
+
['таблица `## Единицы` пуста'],
|
|
383
|
+
'нельзя зафиксировать канон для писателей, которых нет в списке. Пустая таблица под «да» — '
|
|
384
|
+
+ 'доказанный пропуск, а не неизвестность.');
|
|
385
|
+
}
|
|
386
|
+
const names = rows.map((r) => r.name.toLowerCase());
|
|
387
|
+
const dupes = [...new Set(names.filter((n, i) => names.indexOf(n) !== i))];
|
|
388
|
+
if (dupes.length) {
|
|
389
|
+
cannotCheck('в таблице единиц повторяются строки: ' + dupes.join(', '),
|
|
390
|
+
'одна единица — одна строка; иначе один канон приписан двум разным писателям под одним именем');
|
|
391
|
+
}
|
|
392
|
+
if (rows.length === 1) {
|
|
393
|
+
cannotCheck('в плане одна пишущая единица — это последовательный запуск',
|
|
394
|
+
'столкновение имён существует ТОЛЬКО в объединении параллельных записей; у объединения из '
|
|
395
|
+
+ 'одной записи его быть не может. Последовательный запуск — законная альтернатива '
|
|
396
|
+
+ 'зафиксированному канону, а не обход проверки.');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 4. THE LOAD-BEARING CHECK. Two or more writers ⇒ the canon must be NAMED and PINNED.
|
|
400
|
+
const canonRel = header(text, 'Канон');
|
|
401
|
+
if (canonRel === null || canonRel === '') {
|
|
402
|
+
proven('' + rows.length + ' параллельных пишущих единиц, а канон не назван',
|
|
403
|
+
rows.map((r) => r.name),
|
|
404
|
+
'каждая единица выведет имена моделей, значения перечислений, пути маршрутов и порядковые '
|
|
405
|
+
+ 'номера из общего источника самостоятельно — и будет внутри себя права. Столкновение '
|
|
406
|
+
+ 'появится только при слиянии, где его уже никто не относит к диспатчу.');
|
|
407
|
+
}
|
|
408
|
+
const canonAbs = path.join(root, canonRel);
|
|
409
|
+
let canonBytes;
|
|
410
|
+
try {
|
|
411
|
+
if (!fs.statSync(canonAbs).isFile()) {
|
|
412
|
+
proven('канон назван, но это не файл', [canonRel], 'закрепление за несуществующим объектом ' +
|
|
413
|
+
'ничего не закрепляет.');
|
|
414
|
+
}
|
|
415
|
+
canonBytes = fs.readFileSync(canonAbs);
|
|
416
|
+
} catch {
|
|
417
|
+
proven('канон назван, но не читается', [canonRel],
|
|
418
|
+
'план ссылается на документ, которого нет: пин указывает в пустоту, а исполнители при этом '
|
|
419
|
+
+ 'получили ссылку и будут считать её действующей.');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const declared = header(text, 'Хеш канона');
|
|
423
|
+
if (declared === null || declared === '') {
|
|
424
|
+
proven('канон назван, но его хеш не записан', [canonRel],
|
|
425
|
+
'без записанного хеша «канон не менялся» и «канон переписали после диспатча» выглядят '
|
|
426
|
+
+ 'одинаково: путь остаётся тем же в обоих случаях.');
|
|
427
|
+
}
|
|
428
|
+
if (!HEX64.test(declared)) {
|
|
429
|
+
cannotCheck('`Хеш канона` не похож на sha256: ' + declared,
|
|
430
|
+
'нужны ровно 64 шестнадцатеричных знака — `sha256sum ' + canonRel + '`');
|
|
431
|
+
}
|
|
432
|
+
const live = crypto.createHash('sha256').update(canonBytes).digest('hex');
|
|
433
|
+
if (live.toLowerCase() !== declared.toLowerCase()) {
|
|
434
|
+
proven('канон изменился после фиксации',
|
|
435
|
+
[canonRel, 'записано: ' + declared.toLowerCase(), 'на диске: ' + live],
|
|
436
|
+
'исполнители получили ссылку на документ, который с тех пор переписали: часть из них решает '
|
|
437
|
+
+ 'по старой редакции, часть по новой, и обе половины внутри себя последовательны. '
|
|
438
|
+
+ 'Перефиксируйте канон и перезапустите диспатч — либо верните файл к записанному состоянию.');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 5. The canon's own consistency. A frozen document that contradicts itself freezes the collision.
|
|
442
|
+
const canonText = canonBytes.toString('utf-8');
|
|
443
|
+
|
|
444
|
+
const collisions = ordinalCollisions(canonText);
|
|
445
|
+
if (collisions.length) {
|
|
446
|
+
const lines = [];
|
|
447
|
+
for (const c of collisions) {
|
|
448
|
+
lines.push('порядковый номер ' + c.ordinal
|
|
449
|
+
+ (c.bodiesDiffer ? ' у ДВУХ РАЗНЫХ разделов' : ' повторён дословно') + ':');
|
|
450
|
+
for (const w of c.where) lines.push(' ' + w);
|
|
451
|
+
}
|
|
452
|
+
proven('в каноне один порядковый номер у соседних разделов', lines,
|
|
453
|
+
'номер существует, чтобы на него можно было СОСЛАТЬСЯ. Два соседа под одним номером делают '
|
|
454
|
+
+ 'ссылку неразрешимой, а при слиянии двух веток — молчаливой: обе стороны правы у себя. '
|
|
455
|
+
+ 'Перенумеруйте один из них.');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const claims = countClaims(canonText);
|
|
459
|
+
const broken = brokenCounts(claims);
|
|
460
|
+
if (broken.length) {
|
|
461
|
+
proven('в каноне перечень противоречит собственному числу',
|
|
462
|
+
broken.map((c) => 'строка ' + c.line + ': заявлено ' + c.declared + ' («' + c.label
|
|
463
|
+
+ '»), перечислено ' + c.counted),
|
|
464
|
+
'число рядом с перечнем читается как проверка перечня. Когда они расходятся, читатель верит '
|
|
465
|
+
+ 'числу, а работает по перечню — и расхождение переживает и ревью, и слияние.');
|
|
466
|
+
}
|
|
467
|
+
const contradicted = contradictedClaims(claims);
|
|
468
|
+
if (contradicted.length) {
|
|
469
|
+
const lines = [];
|
|
470
|
+
for (const c of contradicted) {
|
|
471
|
+
lines.push('«' + c.label + '» перечислено по-разному:');
|
|
472
|
+
for (const w of c.where) lines.push(' ' + w);
|
|
473
|
+
}
|
|
474
|
+
proven('в каноне два перечня об одном и том же не совпадают', lines,
|
|
475
|
+
'это подпись слияния без канона: две ветки правили один перечень, обе оставили свою версию, '
|
|
476
|
+
+ 'и ни одна из них не полна. Оставьте ОДИН перечень и ссылайтесь на него.');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
say('✅ канон зафиксирован и цел: ' + canonRel + ' (sha256 совпал), '
|
|
480
|
+
+ rows.length + ' параллельных пишущих единиц');
|
|
481
|
+
say(' Проверено внутри канона: порядковые номера различимы у соседей ('
|
|
482
|
+
+ headings(canonText).length + ' заголовков), перечни держат своё число (' + claims.length + ')');
|
|
483
|
+
say(' Ограничение: совпавший хеш доказывает, что канон НЕ МЕНЯЛСЯ, — но не то, что он перечислил '
|
|
484
|
+
+ 'все разделяемые выборы. Полнота канона остаётся суждением координатора (слой 3).');
|
|
485
|
+
process.exit(0);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
main();
|
|
490
|
+
} catch (err) {
|
|
491
|
+
// Even an unexpected failure must not read as "clean".
|
|
492
|
+
cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
|
|
493
|
+
}
|