@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,514 @@
1
+ 'use strict';
2
+
3
+ // The deterministic half of the long-running-job rule. The rule itself is prose a model executes —
4
+ // layer 2. This is layer 1, and only for whoever runs it.
5
+ //
6
+ // THE DEFECT IT EXISTS FOR. Some work takes MINUTES: transcribe an hour of audio and cut it,
7
+ // generate an image. A plain request-response cannot carry that, and the three ways it breaks are
8
+ // one confusion wearing three coats — NO ANSWER and STILL RUNNING are different facts, and a design
9
+ // that spells them identically cannot tell them apart afterwards. Silence is produced EQUALLY by a
10
+ // live job, a dead worker and a severed proxy: the request dies at the intermediary's idle timeout
11
+ // while the work completes (the user is charged and shown an error), the retry restarts instead of
12
+ // continuing (the external bill doubles), and the user who can see no state presses the button again.
13
+ //
14
+ // Three sides are covered here, deliberately:
15
+ // 1. BEHAVIOUR — the real utility, real files, real exit codes (P1-P17)
16
+ // 2. FORCE — the rule's text still MANDATES, not suggests (P18-P19); a polite rephrasing
17
+ // must turn this suite red, because a suite that tests a contract's VOCABULARY
18
+ // and not its FORCE has already been observed to pass an optional obligation
19
+ // (MEASURED on this package, commit 37916bd3)
20
+ // 3. WIRING — the seam, the counters and the mutation registry agree (P20-P22)
21
+
22
+ const { test, describe } = require('node:test');
23
+ const assert = require('node:assert/strict');
24
+ const { spawnSync } = require('node:child_process');
25
+ const fs = require('node:fs');
26
+ const os = require('node:os');
27
+ const path = require('node:path');
28
+
29
+ const PKG = path.resolve(__dirname, '..', '..');
30
+ const TPL = path.join(PKG, 'templates', '.claude');
31
+ const CHECK = path.join(TPL, 'hooks', 'check-job-contract.cjs');
32
+ const CONTRACT = 'docs/long-job-contract.md';
33
+
34
+ const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
35
+
36
+ /** Build a throwaway project and run the REAL checker over it. */
37
+ function check(files) {
38
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-job-')));
39
+ try {
40
+ for (const [rel, body] of Object.entries(files || {})) {
41
+ const abs = path.join(dir, rel);
42
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
43
+ fs.writeFileSync(abs, body);
44
+ }
45
+ const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
46
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
47
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
48
+ }
49
+
50
+ const ID = 'job_id';
51
+ const STATES = ['выполняется', 'успех', 'отказ'];
52
+ const SEEN = {
53
+ 'выполняется': '«идёт нарезка, 2 из 7»',
54
+ 'успех': 'список готовых клипов со ссылками',
55
+ 'отказ': '«не удалось: ffmpeg код 1» и кнопка «повторить»',
56
+ };
57
+ const PROOF = 'GET /api/jobs/j-42 → 200, job_id=j-42, 2026-09-01';
58
+ const healthyRows = () => STATES.map((name) => ({ name, status: 'ПРОВЕРЕН', seen: SEEN[name], evidence: PROOF }));
59
+
60
+ /**
61
+ * A contract in whatever state the case needs.
62
+ *
63
+ * Defaults are the HEALTHY ones so that every case stays about the single thing it changes; a case
64
+ * that wants silence passes `null` for that field, and silence is asserted as its own outcome.
65
+ */
66
+ function contract({
67
+ long = 'да', id = ID,
68
+ lives = 'ответ `POST /api/clips` → поле `job_id`; чтение `GET /api/jobs/{job_id}`',
69
+ issued = 'до начала работы', create = 'идентификатор', ceiling = '15 мин', window = '60 с',
70
+ silence = 'неизвестно', resume = 'идемпотентный-ключ', run = 'ВЫПОЛНЕНА', reason = null,
71
+ rows = healthyRows(),
72
+ } = {}) {
73
+ const line = (label, value) => (value === null ? '' : '**' + label + ':** ' + value + '\n');
74
+ return '# Контракт долгой задачи\n\n'
75
+ + line('Долгие задачи', long)
76
+ + line('Идентификатор задачи', id)
77
+ + line('Где живёт', lives)
78
+ + line('Выдаётся', issued)
79
+ + line('Ответ на создание', create)
80
+ + line('Предельное время задачи', ceiling)
81
+ + line('Таймаут посредника', window)
82
+ + line('Молчание', silence)
83
+ + line('Продолжение при повторе', resume)
84
+ + line('Проверка выполнена', run)
85
+ + line('Причина', reason)
86
+ + '\n## Состояния\n\n'
87
+ + '| Состояние | Статус | Что видит пользователь | Доказательство |\n'
88
+ + '|---|---|---|---|\n'
89
+ + rows.map((r) => '| ' + r.name + ' | ' + r.status + ' | ' + r.seen + ' | ' + r.evidence + ' |')
90
+ .join('\n') + (rows.length ? '\n' : '');
91
+ }
92
+
93
+ describe('the long-job checker answers three questions and never confuses two of them', () => {
94
+ test('P1 - a contract with a handle, three states and a resuming retry is exit 0', () => {
95
+ const r = check({ [CONTRACT]: contract() });
96
+ assert.equal(r.code, 0, r.out);
97
+ assert.match(r.out, /все 3 состояния различимы и проверены по идентификатору `job_id`/, r.out);
98
+ assert.match(r.out, /выдаётся ДО начала работы/, r.out);
99
+ // Both durations are printed next to the verdict, always — see P14.
100
+ assert.match(r.out, /потолок 15 мин против окна 60 с/, r.out);
101
+ // The receipt must state what it does NOT prove, or a reader upgrades it into a survival proof.
102
+ assert.match(r.out, /Ограничение:/, r.out);
103
+ });
104
+
105
+ test('P2 - no contract at all is exit 2, and the hint refuses the wrong reading', () => {
106
+ const r = check({});
107
+ assert.equal(r.code, 2, r.out);
108
+ assert.match(r.out, /нет файла docs\/long-job-contract\.md/, r.out);
109
+ // «no file» means the QUESTION was never asked — never «there is no long work».
110
+ assert.match(r.out, /НЕ ЗАДАВАЛСЯ/, r.out);
111
+ });
112
+
113
+ test('P3 - «долгие задачи: нет» is a legitimate answer and exits 2, never 0', () => {
114
+ const r = check({ [CONTRACT]: contract({ long: 'нет' }) });
115
+ assert.equal(r.code, 2, 'a product with no long work has nothing to check: ' + r.out);
116
+ assert.match(r.out, /законный ответ/, r.out);
117
+ });
118
+
119
+ test('P4 - a missing or unrecognised declaration is exit 2 with the closed set printed', () => {
120
+ const absent = check({ [CONTRACT]: contract({ long: null }) });
121
+ assert.equal(absent.code, 2, absent.out);
122
+ assert.match(absent.out, /Долгие задачи/, absent.out);
123
+
124
+ for (const bad of ['иногда', '', 'yes']) {
125
+ const r = check({ [CONTRACT]: contract({ long: bad }) });
126
+ assert.equal(r.code, 2, '«' + bad + '» must not be read as a verdict: ' + r.out);
127
+ assert.match(r.out, /допустимы ровно/, r.out);
128
+ }
129
+ });
130
+
131
+ test('P5 - «НЕ ВЫПОЛНЕНА» with a closed-list reason is honest UNKNOWN, exit 2', () => {
132
+ const ok = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: 'no-worker' }) });
133
+ assert.equal(ok.code, 2, ok.out);
134
+ assert.match(ok.out, /НЕ ВЫПОЛНЕНА, причина: no-worker/, ok.out);
135
+ assert.match(ok.out, /честное «неизвестно»/, ok.out);
136
+
137
+ // A missing reason and a free-text reason are DIFFERENT mistakes with different repairs.
138
+ const none = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: null }) });
139
+ assert.equal(none.code, 2, none.out);
140
+ assert.match(none.out, /без строки `\*\*Причина:\*\*`/, none.out);
141
+
142
+ for (const bad of ['было некогда', 'no-workers', 'no-worker и not-deployed']) {
143
+ const r = check({ [CONTRACT]: contract({ run: 'НЕ ВЫПОЛНЕНА', reason: bad }) });
144
+ assert.equal(r.code, 2, r.out);
145
+ assert.match(r.out, /не из закрытого списка/, '«' + bad + '»: ' + r.out);
146
+ }
147
+ });
148
+
149
+ test('P6 - LOAD-BEARING: silence declared as «выполняется» is a PROVEN defect, exit 1', () => {
150
+ const r = check({ [CONTRACT]: contract({ silence: 'выполняется' }) });
151
+ assert.equal(r.code, 1, 'silence must not be readable as a state: ' + r.out);
152
+ assert.match(r.out, /молчание объявлено состоянием «выполняется»/, r.out);
153
+ // The reason must be the MECHANISM: three different facts are silent in exactly the same way.
154
+ assert.match(r.out, /умерший исполнитель и оборванный посредник молчат ОДИНАКОВО/, r.out);
155
+ assert.match(r.out, /`job_id`/, 'the identifier is what tells them apart: ' + r.out);
156
+ });
157
+
158
+ test('P7 - an identifier issued only WITH the result is exit 1', () => {
159
+ const r = check({ [CONTRACT]: contract({ issued: 'после завершения' }) });
160
+ assert.equal(r.code, 1, r.out);
161
+ assert.match(r.out, /идентификатор выдаётся только вместе с результатом/, r.out);
162
+ assert.match(r.out, /деньги за внешние вызовы уже потрачены/, r.out);
163
+ });
164
+
165
+ test('P8 - a retry that RESTARTS is failure class 2, declared and named, exit 1', () => {
166
+ const r = check({ [CONTRACT]: contract({ resume: 'нет' }) });
167
+ assert.equal(r.code, 1, r.out);
168
+ assert.match(r.out, /повтор начинает работу заново/, r.out);
169
+ assert.match(r.out, /идемпотентный-ключ \| запись-в-хранилище \| аренда-исполнителя/, r.out);
170
+ // The webhook kinship is NAMED at the seam and the mechanism is kept distinct — the inbound
171
+ // half belongs to the neighbouring rule and is not restated here.
172
+ assert.match(r.out, /ВХОДЯЩИХ вебхуков — соседнее правило/, r.out);
173
+
174
+ for (const good of ['идемпотентный-ключ', 'запись-в-хранилище', 'аренда-исполнителя']) {
175
+ assert.equal(check({ [CONTRACT]: contract({ resume: good }) }).code, 0, good);
176
+ }
177
+ const bogus = check({ [CONTRACT]: contract({ resume: 'как-нибудь' }) });
178
+ assert.equal(bogus.code, 2, 'an unmapped mechanism is «could not check», not a pass: ' + bogus.out);
179
+ });
180
+
181
+ test('P9 - a synchronous answer to work longer than the window is exit 1', () => {
182
+ const r = check({ [CONTRACT]: contract({ create: 'результат' }) });
183
+ assert.equal(r.code, 1, r.out);
184
+ assert.match(r.out, /ответом на создание объявлен РЕЗУЛЬТАТ/, r.out);
185
+ // Both numbers, in seconds, so the comparison is auditable and not taken on trust.
186
+ assert.match(r.out, /15 мин \(900 с\)/, r.out);
187
+ assert.match(r.out, /60 с \(60 с\)/, r.out);
188
+ });
189
+
190
+ test('P10 - BOUNDED: a synchronous answer is legal when the work fits inside the window', () => {
191
+ // Widening this to "any synchronous answer is a defect" would refuse the correct configuration
192
+ // of every product with one slow-ish endpoint, and a check that refuses the correct
193
+ // configuration gets switched off. The escape it leaves is VISIBLE: both numbers are printed.
194
+ const r = check({ [CONTRACT]: contract({ create: 'результат', ceiling: '3 с' }) });
195
+ assert.equal(r.code, 0, r.out);
196
+ assert.match(r.out, /Ограниченное исключение/, r.out);
197
+ assert.match(r.out, /3 с < 60 с/, r.out);
198
+ assert.match(r.out, /Занижённый потолок — ложь в самой\s+декларации/, r.out);
199
+ });
200
+
201
+ test('P11 - a duration without a unit is «could not check», exit 2', () => {
202
+ for (const field of ['ceiling', 'window']) {
203
+ const bare = check({ [CONTRACT]: contract({ [field]: '60' }) });
204
+ assert.equal(bare.code, 2, field + ' bare number: ' + bare.out);
205
+ assert.match(bare.out, /не разбирается как длительность/, bare.out);
206
+ const junk = check({ [CONTRACT]: contract({ [field]: 'долго' }) });
207
+ assert.equal(junk.code, 2, field + ' junk: ' + junk.out);
208
+ const missing = check({ [CONTRACT]: contract({ [field]: null }) });
209
+ assert.equal(missing.code, 2, field + ' missing: ' + missing.out);
210
+ }
211
+ // ...and the units that ARE recognised must keep the check usable.
212
+ assert.equal(check({ [CONTRACT]: contract({ ceiling: '2 ч', window: '30 сек' }) }).code, 0);
213
+ assert.equal(check({ [CONTRACT]: contract({ ceiling: '1,5 мин', window: '60 s' }) }).code, 0);
214
+ });
215
+
216
+ test('P12 - the identifier must be a FIELD NAME, and its home must mention it', () => {
217
+ const described = check({ [CONTRACT]: contract({ id: 'идентификатор задачи' }) });
218
+ assert.equal(described.code, 2, 'a description is not a field: ' + described.out);
219
+ assert.match(described.out, /нужно ИМЯ ПОЛЯ/, described.out);
220
+
221
+ const absent = check({ [CONTRACT]: contract({ id: null }) });
222
+ assert.equal(absent.code, 2, absent.out);
223
+ assert.match(absent.out, /второй раз находит свою задачу/i, absent.out);
224
+
225
+ const orphan = check({ [CONTRACT]: contract({ lives: 'в ответе создания' }) });
226
+ assert.equal(orphan.code, 1, 'the field is named but lives nowhere: ' + orphan.out);
227
+ assert.match(orphan.out, /не упоминает названное поле/, orphan.out);
228
+
229
+ const noHome = check({ [CONTRACT]: contract({ lives: null }) });
230
+ assert.equal(noHome.code, 2, noHome.out);
231
+ });
232
+
233
+ test('P13 - a state left out of the table is a PROVEN omission and is NAMED', () => {
234
+ for (const dropped of STATES) {
235
+ const rows = healthyRows().filter((r) => r.name !== dropped);
236
+ const r = check({ [CONTRACT]: contract({ rows }) });
237
+ assert.equal(r.code, 1, dropped + ' silently missing: ' + r.out);
238
+ assert.match(r.out, new RegExp(dropped), 'the missing state must be named: ' + r.out);
239
+ }
240
+ const empty = check({ [CONTRACT]: contract({ rows: [] }) });
241
+ assert.equal(empty.code, 1, empty.out);
242
+ assert.match(empty.out, /3 из 3/, empty.out);
243
+ // The two-state design is the defect, and the message must say so in those words.
244
+ assert.match(empty.out, /Два \(«идёт» и «готово»\) — это и есть/, empty.out);
245
+ });
246
+
247
+ test('P14 - two states the USER cannot tell apart are one state, exit 1', () => {
248
+ const same = check({
249
+ [CONTRACT]: contract({
250
+ rows: STATES.map((name) => ({ name, status: 'ПРОВЕРЕН', seen: 'готово', evidence: PROOF })),
251
+ }),
252
+ });
253
+ assert.equal(same.code, 1, same.out);
254
+ assert.match(same.out, /выглядят для пользователя ОДИНАКОВО/, same.out);
255
+ assert.match(same.out, /успех и отказ/, same.out);
256
+
257
+ // Case and spacing do not separate two states for a user, and must not separate them here.
258
+ const cosmetic = check({
259
+ [CONTRACT]: contract({
260
+ rows: STATES.map((name, i) => ({
261
+ name, status: 'ПРОВЕРЕН', seen: i === 1 ? 'Готово.' : (i === 2 ? 'готово' : SEEN[name]),
262
+ evidence: PROOF,
263
+ })),
264
+ }),
265
+ });
266
+ assert.equal(cosmetic.code, 1, 'punctuation is not a difference a user can act on: ' + cosmetic.out);
267
+
268
+ // A state with no visible sign at all does not exist for the user.
269
+ const blind = check({
270
+ [CONTRACT]: contract({
271
+ rows: healthyRows().map((r, i) => (i === 2 ? { ...r, seen: '' } : r)),
272
+ }),
273
+ });
274
+ assert.equal(blind.code, 1, blind.out);
275
+ assert.match(blind.out, /без наблюдаемого признака/, blind.out);
276
+ });
277
+
278
+ test('P15 - a proof that does not name the identifier is exit 1', () => {
279
+ // The kinship with the measured deployment finding: a check must use the handle the system
280
+ // ISSUED. Without it, "I read MY job" and "the server answered at all" are written the same.
281
+ const anon = check({
282
+ [CONTRACT]: contract({
283
+ rows: healthyRows().map((r) => ({ ...r, evidence: 'проверено вручную' })),
284
+ }),
285
+ });
286
+ assert.equal(anon.code, 1, anon.out);
287
+ assert.match(anon.out, /не называет идентификатор `job_id`/, anon.out);
288
+ for (const name of STATES) assert.match(anon.out, new RegExp(name), anon.out);
289
+
290
+ // An empty cell and the shipped bracketed placeholder are the same fact: no proof at all.
291
+ for (const evidence of ['', '[адрес и дата]']) {
292
+ const r = check({
293
+ [CONTRACT]: contract({ rows: healthyRows().map((row) => ({ ...row, evidence })) }),
294
+ });
295
+ assert.equal(r.code, 1, 'an untouched template must not pass: ' + r.out);
296
+ assert.match(r.out, /без всякого доказательства/, r.out);
297
+ }
298
+ });
299
+
300
+ test('P16 - «НЕ ПРОВЕРЕН» under a declared ВЫПОЛНЕНА is a contradiction, exit 1', () => {
301
+ const rows = healthyRows().map((r, i) => (i === 2 ? { ...r, status: 'НЕ ПРОВЕРЕН' } : r));
302
+ const r = check({ [CONTRACT]: contract({ rows }) });
303
+ assert.equal(r.code, 1, r.out);
304
+ assert.match(r.out, /отказ/, r.out);
305
+ assert.match(r.out, /ложная квитанция/, r.out);
306
+
307
+ // A malformed or duplicated table is «could not check», never a pass and never a proven loss.
308
+ const bogus = check({
309
+ [CONTRACT]: contract({ rows: healthyRows().map((row) => ({ ...row, status: 'ок' })) }),
310
+ });
311
+ assert.equal(bogus.code, 2, bogus.out);
312
+ assert.match(bogus.out, /нераспознанный статус состояния/, bogus.out);
313
+
314
+ const dupe = check({
315
+ [CONTRACT]: contract({ rows: [...healthyRows(), healthyRows()[1]] }),
316
+ });
317
+ assert.equal(dupe.code, 2, dupe.out);
318
+ assert.match(dupe.out, /повторяются строки/, dupe.out);
319
+ });
320
+
321
+ test('P17 - a PROVEN defect outranks an unanswered question, and cannot-check is never clean', () => {
322
+ // ORDERING, and it is deliberate: a table nobody can parse must not be able to HIDE a declared
323
+ // «молчание = выполняется». 1 beats 2, exactly as in check-look-trace.cjs.
324
+ const both = check({
325
+ [CONTRACT]: contract({
326
+ silence: 'выполняется',
327
+ rows: healthyRows().map((row) => ({ ...row, status: 'ок' })),
328
+ }),
329
+ });
330
+ assert.equal(both.code, 1, 'the malformed table swallowed the proven defect: ' + both.out);
331
+ assert.match(both.out, /молчание объявлено состоянием/, both.out);
332
+
333
+ const absent = spawnSync(process.execPath, [CHECK, path.join(os.tmpdir(), 'p-rep-job-absent-dir')],
334
+ { encoding: 'utf8' });
335
+ assert.equal(absent.status, 2, absent.stdout + absent.stderr);
336
+ assert.match(absent.stdout, /проверка НЕ выполнена/, absent.stdout);
337
+ });
338
+ });
339
+
340
+ /**
341
+ * The rule's FORCE, kept apart from its vocabulary.
342
+ *
343
+ * MEASURED on this package 2026-09-01 (commit 37916bd3): a suite of twelve keyword predicates over
344
+ * the swarm receipt contract was fully satisfied by a block retitled "Coordination note" whose
345
+ * imperative had been softened to "Where convenient" — the registered mutation SURVIVED. These
346
+ * predicates are that lesson applied here: the heading must still refuse, each obligation must still
347
+ * read as a duty, and the closed set must still be mandatory.
348
+ */
349
+ function ruleForceProblems(text) {
350
+ const problems = [];
351
+ if (!/^## «Нет ответа» — это не «выполняется»$/m.test(text)) {
352
+ problems.push('the heading no longer refuses the silence-is-running reading');
353
+ }
354
+ if (!/Идентификатор ОБЯЗАН выдаваться ДО начала работы/.test(text)) {
355
+ problems.push('the handle obligation is advisory, not imperative');
356
+ }
357
+ if (!/Набор ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ/.test(text)) {
358
+ problems.push('the three states are a menu, not a mandatory set');
359
+ }
360
+ if (!/Повтор ОБЯЗАН ПРОДОЛЖАТЬ, а не начинать заново/.test(text)) {
361
+ problems.push('the retry obligation is advisory, not imperative');
362
+ }
363
+ // The identifier requirement must be CONCRETE — «name the field», not «do it asynchronously».
364
+ if (!/назовите ПОЛЕ/.test(text) || !/где оно живёт/.test(text)) {
365
+ problems.push('the identifier is demanded in the abstract, without naming a field or its home');
366
+ }
367
+ // Each failure class must arrive with the symptom the CLIENT sees — a class named without its
368
+ // признак is a label, and a label cannot be recognised in the field.
369
+ for (const [cls, sign] of [
370
+ ['разрыв', /ошибка при успешно потраченных деньгах/],
371
+ ['повтор-заново', /счёт за внешние вызовы удваивается/],
372
+ ['третья-копия', /жмёт кнопку ещё раз/]]) {
373
+ if (!new RegExp('`' + cls + '`').test(text)) problems.push(cls + ' is not named');
374
+ if (!sign.test(text)) problems.push(cls + ' carries no client-side symptom');
375
+ }
376
+ for (const state of ['выполняется', 'успех', 'отказ']) {
377
+ if (!new RegExp('`' + state + '`').test(text)) problems.push('state ' + state + ' is not named');
378
+ }
379
+ // The neighbouring webhook rule is REFERENCED, not rewritten, and the mechanisms are kept apart.
380
+ if (!/Смежность с вебхуками названа, но не переписана/.test(text)
381
+ || !/своё соседнее правило/i.test(text)) {
382
+ problems.push('the webhook adjacency is not delegated to its own neighbouring rule');
383
+ }
384
+ if (!/ИЗВНЕ/.test(text) || !/ваш же клиент/.test(text)) {
385
+ problems.push('the two retry mechanisms are conflated');
386
+ }
387
+ if (!/Слой 3–4/.test(text) || !/НЕ МОЖЕТ по названной причине/.test(text)) {
388
+ problems.push('the layer marking hides what stays judgment');
389
+ }
390
+ if (!/Код `2` никогда не значит «всё в порядке»/.test(text)) {
391
+ problems.push('exit 2 may be read as clean');
392
+ }
393
+ return problems;
394
+ }
395
+
396
+ describe('the rule still mandates, and the pipeline still calls the check', () => {
397
+ test('P18 - the shipped rule satisfies every force predicate', () => {
398
+ assert.deepEqual(ruleForceProblems(read('templates/.claude/rules/long-running-job.md')), []);
399
+ });
400
+
401
+ test('P19 - MUTATION: a polite rephrasing must turn this suite red', () => {
402
+ const source = read('templates/.claude/rules/long-running-job.md');
403
+
404
+ // Retitle only. Every keyword in the file survives; only the refusal in the heading is gone.
405
+ const retitled = source.replace('## «Нет ответа» — это не «выполняется»', '## Замечание о состояниях');
406
+ assert.notEqual(retitled, source, 'mutation fixture did not apply — the heading moved');
407
+ assert.deepEqual(ruleForceProblems(retitled),
408
+ ['the heading no longer refuses the silence-is-running reading'],
409
+ 'a retitled block must fire EXACTLY the heading predicate and nothing else');
410
+
411
+ // Soften «ОБЯЗАН» to «желательно». This is the mutation that survived on the swarm contract.
412
+ const softHandle = source.replace('Идентификатор ОБЯЗАН выдаваться ДО начала работы',
413
+ 'Идентификатор желательно выдавать до начала работы');
414
+ assert.notEqual(softHandle, source, 'mutation fixture did not apply — the sentence moved');
415
+ assert.deepEqual(ruleForceProblems(softHandle),
416
+ ['the handle obligation is advisory, not imperative'],
417
+ 'softening the handle duty must fire EXACTLY the mandate predicate');
418
+
419
+ const softRetry = source.replace('Повтор ОБЯЗАН ПРОДОЛЖАТЬ, а не начинать заново',
420
+ 'Повтор желательно продолжать');
421
+ assert.notEqual(softRetry, source, 'mutation fixture did not apply — the sentence moved');
422
+ assert.deepEqual(ruleForceProblems(softRetry),
423
+ ['the retry obligation is advisory, not imperative'],
424
+ 'softening the retry duty must fire EXACTLY its own predicate');
425
+
426
+ // Downgrade the closed set to a suggestion.
427
+ const optional = source.replace('Набор ЗАКРЫТЫЙ и ОБЯЗАТЕЛЬНЫЙ', 'Набор рекомендуемый');
428
+ assert.deepEqual(ruleForceProblems(optional),
429
+ ['the three states are a menu, not a mandatory set'],
430
+ 'a downgraded set must fire EXACTLY its own predicate');
431
+
432
+ // And deleting the layer honesty must be observable on its own.
433
+ const dishonest = source.replace('НЕ МОЖЕТ по названной причине', 'пока не сделана');
434
+ assert.deepEqual(ruleForceProblems(dishonest), ['the layer marking hides what stays judgment']);
435
+ });
436
+
437
+ test('P20 - Phase 2 of /replicate calls the check as an acceptance criterion', () => {
438
+ const cmd = read('templates/.claude/commands/replicate.md');
439
+ assert.match(cmd, /node \.claude\/hooks\/check-job-contract\.cjs \./,
440
+ 'the pipeline must actually invoke it, not merely mention the rule');
441
+ assert.match(cmd, /\.claude\/rules\/long-running-job\.md/, 'the rule must be reachable from the seam');
442
+ // Exit 2 must be spelled out at the seam too, or the operator reads silence as success.
443
+ const seam = cmd.slice(cmd.indexOf('check-job-contract'));
444
+ assert.match(seam.slice(0, 600), /НЕ «в порядке»/, seam.slice(0, 600));
445
+
446
+ const rule = read('templates/.claude/rules/replicate-pipeline.md');
447
+ assert.match(rule, /check-job-contract\.cjs/, 'the hook inventory must list it');
448
+ // DERIVED, never literal. Three hardcoded counts used to stand here, and a hardcoded count is
449
+ // exactly the shape that split in half on 2026-09-01: a number whose only guard is a reader.
450
+ // The shipped directory and the canonical registry cannot be wrong about their own size, so
451
+ // the inventory prose is compared against THEM — which is also what makes this assertion
452
+ // survive the next hook without an edit, instead of failing for being right.
453
+ const shippedRules = Object.keys(JSON.parse(read('src/rule-components.json'))).length;
454
+ const shippedHooks = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs')).length;
455
+ const WIRED_TO_EVENTS = 4; // session-insights + the three autocommit hooks, per settings.json
456
+ assert.match(rule, new RegExp('\\*\\*Rules \\(' + shippedRules + '\\):\\*\\*'),
457
+ 'the rule inventory must count what the registry actually carries (' + shippedRules + ')');
458
+ assert.match(rule, new RegExp('\\*\\*Hooks \\(' + shippedHooks + ' files'),
459
+ 'the hook inventory must count what the package actually ships (' + shippedHooks + ')');
460
+ assert.match(rule, new RegExp('wired to nothing \\(' + (shippedHooks - WIRED_TO_EVENTS) + '\\):'),
461
+ 'the deliberately-invoked count must equal the shipped total minus the four event hooks');
462
+ });
463
+
464
+ test('P21 - it is a hooks component wired to NO event, and every counter agrees', () => {
465
+ const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
466
+ assert.ok(COMPONENTS.hooks.items['check-job-contract'],
467
+ 'it must be registered, or init/doctor/verify will not know it');
468
+ const settings = read('templates/.claude/settings.json');
469
+ assert.ok(!settings.includes('check-job-contract'),
470
+ 'it must not be wired to an event: this package\'s hooks are non-blocking by contract, so a '
471
+ + 'hook could only print — it could never refuse anything');
472
+
473
+ const statusline = read('templates/.claude/hooks/statusline.cjs');
474
+ const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
475
+ assert.ok(hooks, 'statusline must declare hooksExpected');
476
+ assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
477
+ 'the status line would report a phantom missing hook: ' + hooks[1]);
478
+ const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
479
+ assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
480
+ 'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
481
+
482
+ // The rule counter has the same three-way agreement, and this change moved it too.
483
+ const rules = statusline.match(/rulesExpected:\s*(\d+)/);
484
+ assert.ok(rules, 'statusline must declare rulesExpected');
485
+ const registry = JSON.parse(read('src/rule-components.json'));
486
+ assert.ok(registry['long-running-job'], 'the rule must be in the canonical registry');
487
+ assert.equal(Number(rules[1]), Object.keys(registry).length,
488
+ 'the status line would report a phantom missing rule: ' + rules[1]);
489
+ assert.equal(Number(rules[1]), Object.keys(COMPONENTS.rules.items).length);
490
+ });
491
+
492
+ test('P22 - the mutation registry carries this feature\'s guards', () => {
493
+ const registry = JSON.parse(read('test/mutation-registry.json'));
494
+ const expected = {
495
+ 'job-cannot-check-is-not-clean': 'templates/.claude/hooks/check-job-contract.cjs',
496
+ 'job-silence-is-not-a-state': 'templates/.claude/hooks/check-job-contract.cjs',
497
+ 'job-rule-silence-heading-is-a-refusal': 'templates/.claude/rules/long-running-job.md',
498
+ };
499
+ for (const [id, file] of Object.entries(expected)) {
500
+ const entry = registry.entries.find((e) => e.id === id);
501
+ assert.ok(entry, 'missing targeted mutation id ' + id);
502
+ assert.equal(entry.file, file, id + ' mutates the wrong surface');
503
+ assert.ok(entry.property && entry.property.length > 40,
504
+ id + ' needs a behavioral property, not a cosmetic label');
505
+ assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
506
+ id + ' needs a real source mutation');
507
+ assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
508
+ id + ' must require at least one failing test');
509
+ const target = read(entry.file);
510
+ assert.equal(target.split(entry.mutation.find).length - 1, 1,
511
+ id + ': the mutation anchor must occur exactly once');
512
+ }
513
+ });
514
+ });