@dzhechkov/p-replicator 1.12.0 → 1.13.2

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 (122) hide show
  1. package/.dz-manifest.json +281 -65
  2. package/CHANGELOG.md +148 -1
  3. package/LICENSE +21 -0
  4. package/MULTIPLATFORM_ROADMAP.md +1 -1
  5. package/README/eng/01_quickstart.md +2 -2
  6. package/README/eng/02_user_guide.md +1 -1
  7. package/README/eng/03_admin_guide.md +2 -2
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +2 -2
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/05_architecture.md +1 -1
  14. package/README/ru/README.md +2 -1
  15. package/README/ru/html/index.html +8 -8
  16. package/README.md +150 -9
  17. package/bin/cli.js +0 -0
  18. package/package.json +10 -11
  19. package/sbom.json +604 -64
  20. package/scripts/check-pipeline-gaps.sh +510 -21
  21. package/src/commands/init.js +1 -1
  22. package/src/rule-components.json +5 -1
  23. package/src/utils.js +33 -3
  24. package/templates/.claude/agents/product-discoverer.md +38 -0
  25. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  26. package/templates/.claude/commands/feature.md +72 -6
  27. package/templates/.claude/commands/go.md +6 -8
  28. package/templates/.claude/commands/harvest.md +5 -7
  29. package/templates/.claude/commands/replicate.md +169 -44
  30. package/templates/.claude/commands/start.md +28 -7
  31. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  32. package/templates/.claude/hooks/check-canon.cjs +493 -0
  33. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  34. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  35. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  36. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  37. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  38. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  39. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  40. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  41. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  42. package/templates/.claude/hooks/check-ports.cjs +27 -6
  43. package/templates/.claude/hooks/check-review-contract.cjs +205 -0
  44. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  45. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  46. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  47. package/templates/.claude/hooks/statusline.cjs +2 -2
  48. package/templates/.claude/rules/embeddable-widget.md +73 -0
  49. package/templates/.claude/rules/feature-lifecycle.md +5 -6
  50. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  51. package/templates/.claude/rules/long-running-job.md +73 -0
  52. package/templates/.claude/rules/model-call-cost.md +85 -0
  53. package/templates/.claude/rules/replicate-pipeline.md +122 -52
  54. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  55. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  56. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  57. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  58. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  59. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  60. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  61. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  62. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  63. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  64. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  65. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  66. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  67. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  68. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  69. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  70. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  71. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  72. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  73. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  74. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  75. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  76. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  77. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  78. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  79. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  80. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  81. package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
  82. package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
  83. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
  84. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  85. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
  86. package/tests/e2e/feature-contour.test.js +176 -0
  87. package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
  88. package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
  89. package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
  90. package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
  91. package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
  92. package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
  93. package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
  94. package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
  95. package/tests/snapshot/baseline.json +64 -40
  96. package/tests/unit/capture-source-path.test.js +492 -0
  97. package/tests/unit/check-canon.test.js +403 -0
  98. package/tests/unit/check-embed-contract.test.js +422 -0
  99. package/tests/unit/check-external-deps.test.js +363 -0
  100. package/tests/unit/check-file-ownership.test.js +388 -0
  101. package/tests/unit/check-handoff-manifest.test.js +410 -0
  102. package/tests/unit/check-job-contract.test.js +514 -0
  103. package/tests/unit/check-look-origin.test.js +180 -0
  104. package/tests/unit/check-look-trace.test.js +420 -0
  105. package/tests/unit/check-metric-source.test.js +325 -0
  106. package/tests/unit/check-model-cost.test.js +425 -0
  107. package/tests/unit/check-ports.test.js +46 -2
  108. package/tests/unit/check-review-contract.test.js +181 -0
  109. package/tests/unit/check-source-version.test.js +344 -0
  110. package/tests/unit/check-swarm-receipts.test.js +231 -0
  111. package/tests/unit/check-webhook-contract.test.js +536 -0
  112. package/tests/unit/db-port-rule.test.js +8 -2
  113. package/tests/unit/detection-ladder-registry.test.js +2 -2
  114. package/tests/unit/generator-swarm-contract.test.js +287 -0
  115. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  116. package/tests/unit/honest-failure-rules.test.js +106 -9
  117. package/tests/unit/look-phase-contract.test.js +231 -0
  118. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  119. package/tests/unit/quote-provenance.test.js +122 -0
  120. package/tests/unit/sync-templates-guard.test.js +46 -3
  121. package/tests/unit/traceability-completion-gate.test.js +267 -0
  122. package/tests/unit/utils.test.js +40 -3
@@ -0,0 +1,403 @@
1
+ 'use strict';
2
+
3
+ // The deterministic half of «канон до диспатча». There is NO new always-loaded rule for this
4
+ // feature, and that is a decision, not an omission: the package's corpus budget is spent (measured
5
+ // 2026-09-01), the owner refused to raise the threshold, and the doctrine therefore lives where it
6
+ // costs nothing per run — in the checker's own header. What DOES live in the corpus is the seam
7
+ // obligation itself, one paragraph in each of the two writing dispatchers, and P20 below is what
8
+ // keeps that paragraph from decaying into a suggestion.
9
+ //
10
+ // THE DEFECT IT EXISTS FOR. N parallel workers derive names from ONE source document. Each worker
11
+ // is internally consistent; the collision exists only in the UNION of their outputs. So no worker
12
+ // can observe it and no delivery receipt can catch it — every receipt truthfully reports a file
13
+ // that is individually fine.
14
+ //
15
+ // MEASURED, this repository, 2026-09-01, three swarms on one package with no canon pinned: two
16
+ // different steps numbered «Шаг 2.2» on the same line of two worktrees; two adjacent lines both
17
+ // signed «All 13 rules», one with 12 names and one with 11, 13 on disk; 23 merge conflicts in the
18
+ // second branch and 17 in the first; six test failures on the merged tree from suites that were
19
+ // green individually.
20
+ //
21
+ // AND THE ASYMMETRY THAT CHOOSES THE CURE — same package, same merge, same day: the hook counter
22
+ // (17) is pinned by a UNIT TEST and survived intact; the rule counter (13) lived in PROSE and split
23
+ // in two. One repository, one cause, two outcomes. A check held; a paragraph did not.
24
+ //
25
+ // Three sides are covered here, deliberately:
26
+ // 1. BEHAVIOUR — the real utility, real files, real exit codes (P1-P19)
27
+ // 2. FORCE — the seam text still MANDATES, not suggests (P20); a polite rephrasing must turn
28
+ // this suite red, because a suite that tests a contract's VOCABULARY and not its
29
+ // FORCE has already been observed to pass an optional obligation (MEASURED on this
30
+ // package, commit 37916bd3)
31
+ // 3. WIRING — the seam, the counters and the mutation registry agree (P21-P22)
32
+
33
+ const { test, describe } = require('node:test');
34
+ const assert = require('node:assert/strict');
35
+ const { spawnSync } = require('node:child_process');
36
+ const crypto = require('node:crypto');
37
+ const fs = require('node:fs');
38
+ const os = require('node:os');
39
+ const path = require('node:path');
40
+
41
+ const PKG = path.resolve(__dirname, '..', '..');
42
+ const TPL = path.join(PKG, 'templates', '.claude');
43
+ const CHECK = path.join(TPL, 'hooks', 'check-canon.cjs');
44
+ const PLAN = 'docs/dispatch-plan.md';
45
+
46
+ const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
47
+ const sha256 = (s) => crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
48
+
49
+ /** Build a throwaway project and run the REAL checker over it. */
50
+ function check(files) {
51
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-canon-')));
52
+ try {
53
+ for (const [rel, body] of Object.entries(files || {})) {
54
+ const abs = path.join(dir, rel);
55
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
56
+ fs.writeFileSync(abs, body);
57
+ }
58
+ const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
59
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
60
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
61
+ }
62
+
63
+ /** A canon with nothing wrong in it — every case below changes exactly one thing. */
64
+ const CANON_OK = [
65
+ '# Канон проекта',
66
+ '',
67
+ '## Фаза A',
68
+ '',
69
+ '### Шаг 2.1 — имена моделей',
70
+ '',
71
+ 'Order, OrderLine, Refund.',
72
+ '',
73
+ '### Шаг 2.2 — значения перечисления статуса',
74
+ '',
75
+ 'draft, paid, refunded.',
76
+ '',
77
+ '## Фаза B',
78
+ '',
79
+ '- All 3 модели: `Order`, `OrderLine`, `Refund`',
80
+ '',
81
+ ].join('\n');
82
+
83
+ /**
84
+ * A dispatch plan in whatever state the case needs.
85
+ *
86
+ * Defaults are the HEALTHY ones so that every case stays about the single thing it changes; a case
87
+ * that wants silence passes `null` for that field, and silence is asserted as its own outcome.
88
+ */
89
+ function plan({
90
+ writes = 'да', canon = 'docs/canon.md', hash, run = 'ВЫПОЛНЕНА', reason = null,
91
+ units = [['packages/api', 'src/api/**'], ['packages/web', 'src/web/**']],
92
+ } = {}) {
93
+ const line = (label, value) => (value === null || value === undefined ? '' : '**' + label + ':** ' + value + '\n');
94
+ return '# Dispatch plan\n\n'
95
+ + line('Пишущий фан-аут', writes)
96
+ + line('Канон', canon)
97
+ + line('Хеш канона', hash)
98
+ + line('Проверка канона', run)
99
+ + line('Причина', reason)
100
+ + '\n## Единицы\n\n'
101
+ + '| Единица | Что пишет |\n|---|---|\n'
102
+ + units.map(([n, w]) => '| ' + n + ' | ' + w + ' |').join('\n') + '\n';
103
+ }
104
+
105
+ /** The healthy pair: a plan pinned to a canon whose live digest matches. */
106
+ function healthy(canonText = CANON_OK, overrides = {}) {
107
+ return {
108
+ 'docs/canon.md': canonText,
109
+ [PLAN]: plan({ hash: sha256(canonText), ...overrides }),
110
+ };
111
+ }
112
+
113
+ describe('канон до диспатча — the deterministic half', () => {
114
+ test('P1 - a pinned canon with two writing units is CLEAN', () => {
115
+ const r = check(healthy());
116
+ assert.equal(r.code, 0, r.out);
117
+ assert.match(r.out, /✅/);
118
+ // The honesty line is part of the verdict, not decoration: a matching hash proves the canon did
119
+ // not MOVE, never that it was COMPLETE.
120
+ assert.match(r.out, /не то, что он перечислил/, r.out);
121
+ });
122
+
123
+ test('P2 - no plan at all is NOT clean', () => {
124
+ const r = check({ 'docs/canon.md': CANON_OK });
125
+ assert.equal(r.code, 2, r.out);
126
+ assert.match(r.out, /нет файла docs\/dispatch-plan\.md/);
127
+ // The distinction the whole family exists for: "nobody asked" is not "there is no fan-out".
128
+ assert.match(r.out, /НЕ ЗАДАВАЛСЯ/);
129
+ });
130
+
131
+ test('P3 - a read-only fan-out is a legitimate answer, and it is 2 rather than 0', () => {
132
+ const r = check({ [PLAN]: plan({ writes: 'нет' }) });
133
+ assert.equal(r.code, 2, r.out);
134
+ assert.match(r.out, /только в ОБЪЕДИНЕНИИ|объединения записей/);
135
+ assert.doesNotMatch(r.out, /✅/, 'a legitimate non-answer must never be dressed as a pass');
136
+ });
137
+
138
+ test('P4 - an unrecognised closed value stops the check instead of guessing', () => {
139
+ const r = check(healthy(CANON_OK, { writes: 'возможно' }));
140
+ assert.equal(r.code, 2, r.out);
141
+ assert.match(r.out, /нераспознанное значение/);
142
+ });
143
+
144
+ test('P5 - «НЕ ВЫПОЛНЕНА» needs a reason from the closed list, and then it is honest', () => {
145
+ const noReason = check(healthy(CANON_OK, { run: 'НЕ ВЫПОЛНЕНА' }));
146
+ assert.equal(noReason.code, 2, noReason.out);
147
+ assert.match(noReason.out, /без строки `\*\*Причина:\*\*`/);
148
+
149
+ const freeText = check(healthy(CANON_OK, { run: 'НЕ ВЫПОЛНЕНА', reason: 'не успели' }));
150
+ assert.equal(freeText.code, 2, freeText.out);
151
+ assert.match(freeText.out, /не из закрытого списка/);
152
+
153
+ const named = check(healthy(CANON_OK, { run: 'НЕ ВЫПОЛНЕНА', reason: 'решение-отложено' }));
154
+ assert.equal(named.code, 2, named.out);
155
+ assert.match(named.out, /решение-отложено/);
156
+ assert.doesNotMatch(named.out, /✅/);
157
+ });
158
+
159
+ test('P6 - a declared writing fan-out with an empty unit table is a PROVEN gap', () => {
160
+ const r = check(healthy(CANON_OK, { units: [] }));
161
+ assert.equal(r.code, 1, r.out);
162
+ assert.match(r.out, /ни одна единица не названа/);
163
+ });
164
+
165
+ test('P7 - ONE unit is a sequential run: a union of one cannot collide', () => {
166
+ // The rule's own named alternative. Reporting it as a violation would push people to fake a
167
+ // second unit; reporting it as CLEAN would let a real fan-out hide behind one row.
168
+ const r = check(healthy(CANON_OK, { units: [['packages/api', 'src/api/**']] }));
169
+ assert.equal(r.code, 2, r.out);
170
+ assert.match(r.out, /последовательный запуск/);
171
+ assert.doesNotMatch(r.out, /✅/);
172
+ });
173
+
174
+ test('P8 - duplicate unit rows stop the check', () => {
175
+ const r = check(healthy(CANON_OK, {
176
+ units: [['packages/api', 'src/api/**'], ['packages/api', 'src/other/**']],
177
+ }));
178
+ assert.equal(r.code, 2, r.out);
179
+ assert.match(r.out, /повторяются строки/);
180
+ });
181
+
182
+ test('P9 - THE fixture: two parallel writers and no canon named is a PROVEN defect', () => {
183
+ const r = check({ 'docs/canon.md': CANON_OK, [PLAN]: plan({ canon: null, hash: null }) });
184
+ assert.equal(r.code, 1, r.out);
185
+ assert.match(r.out, /канон не назван/);
186
+ assert.match(r.out, /packages\/api/, 'the units must be NAMED, not counted');
187
+ });
188
+
189
+ test('P10 - a canon path that does not exist pins nothing', () => {
190
+ const r = check({ [PLAN]: plan({ canon: 'docs/absent.md', hash: sha256(CANON_OK) }) });
191
+ assert.equal(r.code, 1, r.out);
192
+ assert.match(r.out, /канон назван, но/);
193
+ });
194
+
195
+ test('P11 - a canon named without a recorded hash is a PROVEN defect', () => {
196
+ const r = check({ 'docs/canon.md': CANON_OK, [PLAN]: plan({ hash: null }) });
197
+ assert.equal(r.code, 1, r.out);
198
+ assert.match(r.out, /хеш не записан/);
199
+ // The reason, in words: without a digest the moved and the unmoved canon are written the same.
200
+ assert.match(r.out, /выглядят\s+одинаково|одинаково/);
201
+ });
202
+
203
+ test('P12 - a malformed hash is «could not check», not «mismatch»', () => {
204
+ // Different repairs: a typo in the digest is not evidence that the canon moved.
205
+ const r = check({ 'docs/canon.md': CANON_OK, [PLAN]: plan({ hash: 'deadbeef' }) });
206
+ assert.equal(r.code, 2, r.out);
207
+ assert.match(r.out, /не похож на sha256/);
208
+ });
209
+
210
+ test('P13 - a canon edited after the freeze is caught by the digest', () => {
211
+ const r = check({
212
+ 'docs/canon.md': CANON_OK + '\n### Шаг 2.3 — добавлено после диспатча\n\nтело\n',
213
+ [PLAN]: plan({ hash: sha256(CANON_OK) }),
214
+ });
215
+ assert.equal(r.code, 1, r.out);
216
+ assert.match(r.out, /канон изменился после фиксации/);
217
+ assert.match(r.out, /на диске: [0-9a-f]{64}/);
218
+ });
219
+
220
+ test('P14 - two siblings under ONE ordinal fail, and renumbering one fixes it', () => {
221
+ // The measured «Шаг 2.2» collision, in both directions.
222
+ const collided = CANON_OK.replace('### Шаг 2.1 — имена моделей', '### Шаг 2.2 — имена моделей');
223
+ const bad = check(healthy(collided));
224
+ assert.equal(bad.code, 1, bad.out);
225
+ assert.match(bad.out, /один порядковый номер у соседних разделов/);
226
+ assert.match(bad.out, /2\.2/);
227
+ assert.match(bad.out, /ДВУХ РАЗНЫХ/, 'the report must say the bodies differ — that is the merge signature');
228
+
229
+ const good = check(healthy(CANON_OK));
230
+ assert.equal(good.code, 0, good.out);
231
+ });
232
+
233
+ test('P15 - the SAME ordinal under DIFFERENT parents is legitimate and must pass', () => {
234
+ // Scope is the whole point. A global search over the file cannot tell an addressable pair of
235
+ // steps from an unaddressable one, and an eager guard is not a stricter guard — it is a guard
236
+ // people delete. `Шаг 2.2` under «Фаза A» and under «Фаза B» are two different steps.
237
+ const twoPhases = [
238
+ '# Канон', '', '## Фаза A', '', '### Шаг 2.2 — модели', '', 'Order.', '',
239
+ '## Фаза B', '', '### Шаг 2.2 — маршруты', '', 'POST /orders.', '',
240
+ ].join('\n');
241
+ const r = check(healthy(twoPhases));
242
+ assert.equal(r.code, 0, r.out);
243
+ });
244
+
245
+ test('P16 - a list that contradicts its own number fails; the corrected list passes', () => {
246
+ // The measured «All 13 rules» defect, reduced to its shape.
247
+ const twelve = CANON_OK.replace('- All 3 модели: `Order`, `OrderLine`, `Refund`',
248
+ '- All 3 модели: `Order`, `OrderLine`');
249
+ const bad = check(healthy(twelve));
250
+ assert.equal(bad.code, 1, bad.out);
251
+ assert.match(bad.out, /перечень противоречит собственному числу/);
252
+ assert.match(bad.out, /заявлено 3.*перечислено 2/s);
253
+
254
+ assert.equal(check(healthy(CANON_OK)).code, 0);
255
+ });
256
+
257
+ test('P17 - two claims about ONE population that disagree fail even when both counts are right', () => {
258
+ // The predicate that survives when the arithmetic happens to be correct. The measured defect
259
+ // had two lines both signed «All 13 rules»; had both carried 13 names, a count check alone
260
+ // would have passed a document that still contradicted itself. Split knowledge is the failure.
261
+ const split = CANON_OK.replace('- All 3 модели: `Order`, `OrderLine`, `Refund`',
262
+ '- All 3 модели: `Order`, `OrderLine`, `Refund`\n- All 3 модели: `Order`, `OrderLine`, `Invoice`');
263
+ const r = check(healthy(split));
264
+ assert.equal(r.code, 1, r.out);
265
+ assert.match(r.out, /два перечня об одном и том же не совпадают/);
266
+ });
267
+
268
+ test('P18 - a `#` inside a fenced block is a comment, not a heading', () => {
269
+ // A parser that cannot tell them apart invents siblings that do not exist, and then refuses a
270
+ // document that is fine — the same eagerness P15 guards against, one layer down.
271
+ const withFence = [
272
+ '# Канон', '', '## Фаза A', '', '### Шаг 2.2 — модели', '', 'Order.', '',
273
+ '```bash', '### Шаг 2.2 — это комментарий в примере, а не заголовок', 'echo ok', '```', '',
274
+ ].join('\n');
275
+ const r = check(healthy(withFence));
276
+ assert.equal(r.code, 0, r.out);
277
+ });
278
+
279
+ test('P19 - no non-zero exit ever prints the clean marker', () => {
280
+ // The single property the three-code contract is FOR: an unknown must never read as a
281
+ // reassurance. Asserted over every negative case at once rather than case by case.
282
+ const cases = [
283
+ {}, // no plan at all
284
+ { [PLAN]: plan({ writes: 'нет' }) },
285
+ { [PLAN]: plan({ canon: null, hash: null }) },
286
+ { 'docs/canon.md': CANON_OK, [PLAN]: plan({ hash: 'deadbeef' }) },
287
+ { 'docs/canon.md': CANON_OK, [PLAN]: plan({ hash: sha256('other') }) },
288
+ ];
289
+ for (const files of cases) {
290
+ const r = check(files);
291
+ assert.notEqual(r.code, 0, JSON.stringify(Object.keys(files)));
292
+ assert.doesNotMatch(r.out, /✅/, r.out);
293
+ }
294
+ });
295
+ });
296
+
297
+ /**
298
+ * The seam's FORCE, separately from its vocabulary.
299
+ *
300
+ * Each predicate names ONE reader decision. A test that only checks that the words «канон» and
301
+ * «check-canon.cjs» appear would pass a paragraph that had been softened into a suggestion — this
302
+ * package has MEASURED that exact survival (commit 37916bd3), where a retitle plus a softened
303
+ * imperative left twelve keyword predicates satisfied and the registered mutation alive.
304
+ */
305
+ function seamForceProblems(text) {
306
+ const problems = [];
307
+ if (!/The canon MUST be frozen BEFORE dispatch/.test(text)) {
308
+ problems.push('the canon duty is advisory, not imperative');
309
+ }
310
+ if (!/ONLY IN THE UNION/.test(text)) {
311
+ problems.push('the union-only mechanism is gone — without it the duty looks like bureaucracy');
312
+ }
313
+ if (!/node \.claude\/hooks\/check-canon\.cjs \./.test(text)) {
314
+ problems.push('the deterministic half is not invoked at the seam');
315
+ }
316
+ if (!/sequential/i.test(text)) {
317
+ problems.push('the sequential alternative is gone, which makes the duty unsatisfiable');
318
+ }
319
+ if (!/THE CHECK DID NOT RUN\*\*,?\s*which is\s*never "all clear"/.test(text)) {
320
+ problems.push('exit 2 is not spelled out as «not clean» at the seam');
321
+ }
322
+ return problems;
323
+ }
324
+
325
+ describe('the seam still mandates, and every counter agrees', () => {
326
+ const SEAMS = ['templates/.claude/commands/start.md', 'templates/.claude/commands/feature.md'];
327
+
328
+ test('P20 - both WRITING dispatchers carry the obligation, and softening it fires', () => {
329
+ for (const rel of SEAMS) {
330
+ const source = read(rel);
331
+ assert.deepEqual(seamForceProblems(source), [], rel);
332
+
333
+ // Soften MUST to a recommendation — the mutation that survived on the swarm contract.
334
+ const softened = source.replace('The canon MUST be frozen BEFORE dispatch',
335
+ 'Freezing the canon before dispatch is recommended');
336
+ assert.notEqual(softened, source, rel + ': mutation fixture did not apply — the sentence moved');
337
+ assert.deepEqual(seamForceProblems(softened), ['the canon duty is advisory, not imperative'],
338
+ rel + ': softening the duty must fire EXACTLY the mandate predicate');
339
+
340
+ // Delete the mechanism and keep the duty: a duty whose reason is gone is the first thing a
341
+ // reader drops under time pressure.
342
+ const mechanismless = source.replace('ONLY IN THE UNION', 'sometimes at merge time');
343
+ assert.ok(seamForceProblems(mechanismless).some((p) => p.includes('union-only')), rel);
344
+
345
+ // Turn the invocation into a mention: the layer-1 half must be CALLED, not referenced.
346
+ const mention = source.replace('node .claude/hooks/check-canon.cjs .',
347
+ 'см. .claude/hooks/check-canon.cjs');
348
+ assert.ok(seamForceProblems(mention).some((p) => p.includes('not invoked')), rel);
349
+ }
350
+ });
351
+
352
+ test('P21 - it is a hooks component wired to NO event, and every counter agrees', () => {
353
+ const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
354
+ assert.ok(COMPONENTS.hooks.items['check-canon'],
355
+ 'it must be registered, or init/doctor/verify will not know it');
356
+ const settings = read('templates/.claude/settings.json');
357
+ assert.ok(!settings.includes('check-canon'),
358
+ 'it must not be wired to an event: this package\'s hooks are non-blocking by contract, so a '
359
+ + 'hook could only print — it could never refuse anything');
360
+
361
+ const statusline = read('templates/.claude/hooks/statusline.cjs');
362
+ const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
363
+ assert.ok(hooks, 'statusline must declare hooksExpected');
364
+ assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
365
+ 'the status line would report a phantom missing hook: ' + hooks[1]);
366
+ const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
367
+ assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
368
+ 'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
369
+
370
+ // The THIRD place the count lives — the one that split in half on 2026-09-01 because prose was
371
+ // its only guard.
372
+ const rule = read('templates/.claude/rules/replicate-pipeline.md');
373
+ assert.match(rule, /check-canon\.cjs/, 'the hook inventory must list it');
374
+ const inventory = rule.match(/\*\*Hooks \((\d+) files/);
375
+ assert.ok(inventory, 'the rule must state the hook count it is inventorying');
376
+ assert.equal(Number(inventory[1]), onDisk.length,
377
+ 'the rule prose and the shipped directory disagree — the exact split this feature exists for');
378
+ });
379
+
380
+ test('P22 - the mutation registry carries this feature\'s guards', () => {
381
+ const registry = JSON.parse(read('test/mutation-registry.json'));
382
+ const expected = {
383
+ 'canon-cannot-check-is-not-clean': 'templates/.claude/hooks/check-canon.cjs',
384
+ 'canon-pin-must-still-match': 'templates/.claude/hooks/check-canon.cjs',
385
+ 'canon-ordinal-collision-is-scoped': 'templates/.claude/hooks/check-canon.cjs',
386
+ 'canon-seam-duty-is-imperative': 'templates/.claude/commands/start.md',
387
+ };
388
+ for (const [id, file] of Object.entries(expected)) {
389
+ const entry = registry.entries.find((e) => e.id === id);
390
+ assert.ok(entry, 'missing targeted mutation id ' + id);
391
+ assert.equal(entry.file, file, id + ' mutates the wrong surface');
392
+ assert.ok(entry.property && entry.property.length > 40,
393
+ id + ' needs a behavioral property, not a cosmetic label');
394
+ assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
395
+ id + ' needs a real source mutation');
396
+ assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
397
+ id + ' must require at least one failing test');
398
+ // The anchor must be UNIQUE, or the registered mutation silently rewrites something else.
399
+ assert.equal(read(entry.file).split(entry.mutation.find).length - 1, 1,
400
+ id + ': mutation anchor must occur exactly once');
401
+ }
402
+ });
403
+ });