@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.
Files changed (106) hide show
  1. package/.dz-manifest.json +225 -61
  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 +132 -9
  17. package/bin/cli.js +0 -0
  18. package/package.json +10 -11
  19. package/sbom.json +470 -60
  20. package/scripts/check-pipeline-gaps.sh +0 -0
  21. package/src/commands/init.js +1 -1
  22. package/src/rule-components.json +5 -1
  23. package/src/utils.js +32 -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 +29 -5
  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-source-version.cjs +312 -0
  44. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  45. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  46. package/templates/.claude/hooks/statusline.cjs +2 -2
  47. package/templates/.claude/rules/embeddable-widget.md +73 -0
  48. package/templates/.claude/rules/feature-lifecycle.md +5 -6
  49. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  50. package/templates/.claude/rules/long-running-job.md +73 -0
  51. package/templates/.claude/rules/model-call-cost.md +85 -0
  52. package/templates/.claude/rules/replicate-pipeline.md +121 -52
  53. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  54. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  55. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  56. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  57. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  58. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  59. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  60. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  61. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  62. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  63. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  64. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  65. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  66. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  67. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  68. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  69. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  70. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  71. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  72. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  73. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  74. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  75. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  76. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  77. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  78. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  79. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  80. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  81. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
  82. package/tests/snapshot/baseline.json +60 -38
  83. package/tests/unit/capture-source-path.test.js +492 -0
  84. package/tests/unit/check-canon.test.js +403 -0
  85. package/tests/unit/check-embed-contract.test.js +422 -0
  86. package/tests/unit/check-external-deps.test.js +363 -0
  87. package/tests/unit/check-file-ownership.test.js +388 -0
  88. package/tests/unit/check-handoff-manifest.test.js +410 -0
  89. package/tests/unit/check-job-contract.test.js +514 -0
  90. package/tests/unit/check-look-origin.test.js +180 -0
  91. package/tests/unit/check-look-trace.test.js +420 -0
  92. package/tests/unit/check-metric-source.test.js +325 -0
  93. package/tests/unit/check-model-cost.test.js +425 -0
  94. package/tests/unit/check-ports.test.js +46 -2
  95. package/tests/unit/check-source-version.test.js +344 -0
  96. package/tests/unit/check-swarm-receipts.test.js +231 -0
  97. package/tests/unit/check-webhook-contract.test.js +536 -0
  98. package/tests/unit/db-port-rule.test.js +8 -2
  99. package/tests/unit/detection-ladder-registry.test.js +2 -2
  100. package/tests/unit/generator-swarm-contract.test.js +287 -0
  101. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  102. package/tests/unit/honest-failure-rules.test.js +91 -9
  103. package/tests/unit/look-phase-contract.test.js +231 -0
  104. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  105. package/tests/unit/quote-provenance.test.js +122 -0
  106. package/tests/unit/utils.test.js +40 -3
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ // The deterministic half of «правка объявляет ВЕРСИЮ источника, а не обещает перечитать». There is
4
+ // NO new always-loaded rule: the corpus budget is spent (measured 2026-09-01) and the owner refused
5
+ // to raise the threshold, so the doctrine lives in the checker's header, which is never loaded.
6
+ //
7
+ // WHAT IS ALREADY PRESCRIBED AND IS NOT RE-OPENED. «Take the status from the FILE, not from the
8
+ // worker's narrative» is already required: the coordinator checks each receipt for existence,
9
+ // non-emptiness and mtime freshness before merging. That half stands. This suite covers the one it
10
+ // does not reach.
11
+ //
12
+ // THE UNCOVERED HALF: freshness is checked on the RECEIPT, never on the SOURCE the receipt was
13
+ // derived from. A read copy is a snapshot of the MOMENT OF READING, not of the file, so an edit
14
+ // addressed by a literal string is a race BY CONSTRUCTION. The field case: an edit to Refinement.md
15
+ // died on an assertion because a worker had renumbered the guards between the read and the write.
16
+ // The symmetric case is quieter: three reports saying «the defect is still there» were written from
17
+ // stale copies — and each report was itself perfectly FRESH, so a freshness check on the receipt
18
+ // answered a neighbouring question and handed the answer over as the asked one.
19
+ //
20
+ // WHY A HASH AND NOT AN INSTRUCTION, asserted here rather than merely argued: a rule «re-read
21
+ // immediately before editing» cannot be checked by inspecting the finished file, so its failure is
22
+ // silent — the weakest layer of this package's own ladder. A declared version is checkable by
23
+ // anyone, later, without trusting anybody. P12 is the test that the tool embodies the other half of
24
+ // the same idea: a refusal does not mutate.
25
+
26
+ const { test, describe } = require('node:test');
27
+ const assert = require('node:assert/strict');
28
+ const { spawnSync } = require('node:child_process');
29
+ const crypto = require('node:crypto');
30
+ const fs = require('node:fs');
31
+ const os = require('node:os');
32
+ const path = require('node:path');
33
+
34
+ const PKG = path.resolve(__dirname, '..', '..');
35
+ const TPL = path.join(PKG, 'templates', '.claude');
36
+ const CHECK = path.join(TPL, 'hooks', 'check-source-version.cjs');
37
+ const DECL = 'docs/source-versions.md';
38
+
39
+ const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
40
+ const sha256 = (s) => crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex');
41
+
42
+ /** Build a throwaway project, run the REAL checker, and return the verdict plus a tree fingerprint. */
43
+ function check(files) {
44
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-srcver-')));
45
+ const fingerprint = () => {
46
+ const out = [];
47
+ const walk = (d, rel) => {
48
+ for (const e of fs.readdirSync(d).sort()) {
49
+ const full = path.join(d, e);
50
+ const r = rel ? rel + '/' + e : e;
51
+ if (fs.statSync(full).isDirectory()) walk(full, r);
52
+ else out.push(r + ':' + crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex'));
53
+ }
54
+ };
55
+ walk(dir, '');
56
+ return out.join('\n');
57
+ };
58
+ try {
59
+ for (const [rel, body] of Object.entries(files || {})) {
60
+ const abs = path.join(dir, rel);
61
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
62
+ fs.writeFileSync(abs, body);
63
+ }
64
+ const before = fingerprint();
65
+ const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
66
+ const after = fingerprint();
67
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), before, after };
68
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
69
+ }
70
+
71
+ const REFINEMENT = '# Refinement\n\n1. Страж A\n2. Страж B\n3. Страж C\n';
72
+
73
+ /**
74
+ * A declaration in whatever state the case needs.
75
+ *
76
+ * Defaults are the HEALTHY ones so that every case stays about the single thing it changes; a case
77
+ * that wants silence passes `null` for that field, and silence is asserted as its own outcome.
78
+ */
79
+ function decl({ work = 'да', run = 'ВЫПОЛНЕНА', reason = null, rows } = {}) {
80
+ const line = (label, value) => (value === null || value === undefined ? '' : '**' + label + ':** ' + value + '\n');
81
+ const body = rows === undefined
82
+ ? [['перенумеровать стражей', 'правка', 'docs/Refinement.md', sha256(REFINEMENT)],
83
+ ['дефект F1 всё ещё жив', 'вывод', 'docs/Refinement.md', sha256(REFINEMENT)]]
84
+ : rows;
85
+ return '# Source versions\n\n'
86
+ + line('Правки и выводы', work)
87
+ + line('Проверка версий', run)
88
+ + line('Причина', reason)
89
+ + '\n## Выводы и правки\n\n'
90
+ + '| Что | Вид | Источник | Хеш источника |\n|---|---|---|---|\n'
91
+ + body.map((r) => '| ' + r.join(' | ') + ' |').join('\n') + '\n';
92
+ }
93
+
94
+ const healthy = (over = {}) => ({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl(over) });
95
+
96
+ describe('версия источника объявлена — the deterministic half', () => {
97
+ test('P1 - an edit and a verdict whose source still hashes to the declared value are CLEAN', () => {
98
+ const r = check(healthy());
99
+ assert.equal(r.code, 0, r.out);
100
+ assert.match(r.out, /✅/);
101
+ assert.match(r.out, /1 правк\(и\), 1 вывод\(ов\)/, r.out);
102
+ // The boundary is part of the verdict: a matching hash proves the ground did not move, never
103
+ // that the conclusion standing on it follows.
104
+ assert.match(r.out, /почва НЕ СДВИНУЛАСЬ/, r.out);
105
+ });
106
+
107
+ test('P2 - no declaration at all is NOT clean', () => {
108
+ const r = check({ 'docs/Refinement.md': REFINEMENT });
109
+ assert.equal(r.code, 2, r.out);
110
+ assert.match(r.out, /нет файла docs\/source-versions\.md/);
111
+ assert.match(r.out, /НЕ ЗАДАВАЛСЯ/);
112
+ });
113
+
114
+ test('P3 - «no edits and no verdicts» is legitimate, and it is 2 rather than 0', () => {
115
+ const r = check({ [DECL]: decl({ work: 'нет' }) });
116
+ assert.equal(r.code, 2, r.out);
117
+ assert.doesNotMatch(r.out, /✅/);
118
+ });
119
+
120
+ test('P4 - an unrecognised closed value stops the check instead of guessing', () => {
121
+ const r = check(healthy({ work: 'частично' }));
122
+ assert.equal(r.code, 2, r.out);
123
+ assert.match(r.out, /нераспознанное значение/);
124
+ });
125
+
126
+ test('P5 - «НЕ ВЫПОЛНЕНА» needs a reason from the closed list, and then it is honest', () => {
127
+ const noReason = check(healthy({ run: 'НЕ ВЫПОЛНЕНА' }));
128
+ assert.equal(noReason.code, 2, noReason.out);
129
+ assert.match(noReason.out, /без строки `\*\*Причина:\*\*`/);
130
+
131
+ const freeText = check(healthy({ run: 'НЕ ВЫПОЛНЕНА', reason: 'позже' }));
132
+ assert.equal(freeText.code, 2, freeText.out);
133
+ assert.match(freeText.out, /не из закрытого списка/);
134
+
135
+ const named = check(healthy({ run: 'НЕ ВЫПОЛНЕНА', reason: 'решение-отложено' }));
136
+ assert.equal(named.code, 2, named.out);
137
+ assert.match(named.out, /решение-отложено/);
138
+ // The honest refusal names WHY it matters — a fresh receipt over a stale source.
139
+ assert.match(named.out, /устаревшего источника/);
140
+ assert.doesNotMatch(named.out, /✅/);
141
+ });
142
+
143
+ test('P6 - an empty table under «да» is a PROVEN gap', () => {
144
+ const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({ rows: [] }) });
145
+ assert.equal(r.code, 1, r.out);
146
+ assert.match(r.out, /ни один не назван/);
147
+ });
148
+
149
+ test('P7 - a misspelled Вид is reported, never silently dropped', () => {
150
+ // A row that falls out of the parser is indistinguishable from a row nobody wrote — the same
151
+ // substitution the whole file is about, one level down.
152
+ const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
153
+ rows: [['перенумеровать', 'правочка', 'docs/Refinement.md', sha256(REFINEMENT)]],
154
+ }) });
155
+ assert.equal(r.code, 2, r.out);
156
+ assert.match(r.out, /нераспознанный `Вид`/);
157
+ });
158
+
159
+ test('P8 - a row with no source, or no hash, is a PROVEN defect', () => {
160
+ const noSource = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
161
+ rows: [['перенумеровать', 'правка', '—', sha256(REFINEMENT)]],
162
+ }) });
163
+ assert.equal(noSource.code, 1, noSource.out);
164
+ assert.match(noSource.out, /источник не назван/);
165
+
166
+ const noHash = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
167
+ rows: [['перенумеровать', 'правка', 'docs/Refinement.md', '—']],
168
+ }) });
169
+ assert.equal(noHash.code, 1, noHash.out);
170
+ assert.match(noHash.out, /версия источника не записана/);
171
+ // The load-bearing argument: a promise cannot be checked, a hash can.
172
+ assert.match(noHash.out, /Обещание перечитать проверить\s+нельзя/);
173
+ });
174
+
175
+ test('P9 - a malformed digest is «could not check», not «mismatch»', () => {
176
+ // Different repairs: a typo in the digest is not evidence that the source moved.
177
+ const r = check({ 'docs/Refinement.md': REFINEMENT, [DECL]: decl({
178
+ rows: [['перенумеровать', 'правка', 'docs/Refinement.md', 'deadbeef']],
179
+ }) });
180
+ assert.equal(r.code, 2, r.out);
181
+ assert.match(r.out, /не похож на sha256/);
182
+ });
183
+
184
+ test('P10 - a source that does not exist pins nothing', () => {
185
+ const r = check({ [DECL]: decl({
186
+ rows: [['перенумеровать', 'правка', 'docs/absent.md', sha256(REFINEMENT)]],
187
+ }) });
188
+ assert.equal(r.code, 1, r.out);
189
+ assert.match(r.out, /нет на диске/);
190
+ });
191
+
192
+ test('P11 - THE fixture: declared H1, live H2 — the edit is REFUSED', () => {
193
+ // The field case, reduced: the guards were renumbered between the read and the write.
194
+ const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
195
+ const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl() });
196
+ assert.equal(r.code, 1, r.out);
197
+ assert.match(r.out, /источник изменился после чтения — правка ОТКЛОНЕНА, мутации нет/);
198
+ assert.match(r.out, /перенумеровать стражей/, 'the edit must be NAMED');
199
+ // Both halves of the mechanism must appear: the literal-string race AND the fresh report over a
200
+ // stale source, which is the one a receipt check cannot see.
201
+ assert.match(r.out, /снимок МОМЕНТА ЧТЕНИЯ/);
202
+ assert.match(r.out, /отвечает не на тот вопрос/);
203
+ });
204
+
205
+ test('P12 - the refusal does NOT mutate: the tree is byte-identical after the run', () => {
206
+ // «Отказ без мутации» is a property of the TOOL here, not an instruction to its user. Asserted
207
+ // over the whole tree rather than the named source, because a checker that repaired the
208
+ // declaration — or wrote a report beside it — would also be mutating on a refusal.
209
+ const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
210
+ const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl() });
211
+ assert.equal(r.code, 1, r.out);
212
+ assert.equal(r.after, r.before, 'the checker changed the tree while refusing');
213
+
214
+ // And the same on the clean path, so the property is not accidentally about failure only.
215
+ const ok = check(healthy());
216
+ assert.equal(ok.code, 0, ok.out);
217
+ assert.equal(ok.after, ok.before, 'the checker changed the tree while passing');
218
+ });
219
+
220
+ test('P13 - and the SAME declaration, re-pinned to the live file, is accepted', () => {
221
+ const renumbered = REFINEMENT.replace('2. Страж B', '2. Страж B2');
222
+ const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl({
223
+ rows: [['перенумеровать стражей', 'правка', 'docs/Refinement.md', sha256(renumbered)]],
224
+ }) });
225
+ assert.equal(r.code, 0, r.out);
226
+ });
227
+
228
+ test('P14 - a VERDICT built on a stale source is refused exactly like an edit', () => {
229
+ // The symmetric half, and the quieter one: the report is fresh, its ground is not.
230
+ const renumbered = REFINEMENT.replace('3. Страж C', '3. Страж C — исправлен');
231
+ const r = check({ 'docs/Refinement.md': renumbered, [DECL]: decl({
232
+ rows: [['дефект F1 всё ещё жив', 'вывод', 'docs/Refinement.md', sha256(REFINEMENT)]],
233
+ }) });
234
+ assert.equal(r.code, 1, r.out);
235
+ assert.match(r.out, /\(verdict\)/, 'the report must say WHICH kind was refused');
236
+ });
237
+
238
+ test('P15 - no non-zero exit ever prints the clean marker', () => {
239
+ const cases = [
240
+ {},
241
+ { [DECL]: decl({ work: 'нет' }) },
242
+ { [DECL]: decl({ rows: [] }) },
243
+ { 'docs/Refinement.md': REFINEMENT + 'x', [DECL]: decl() },
244
+ { 'docs/Refinement.md': REFINEMENT, [DECL]: decl({ rows: [['a', 'правка', 'docs/Refinement.md', 'zz']] }) },
245
+ ];
246
+ for (const files of cases) {
247
+ const r = check(files);
248
+ assert.notEqual(r.code, 0, JSON.stringify(Object.keys(files)));
249
+ assert.doesNotMatch(r.out, /✅/, r.out);
250
+ }
251
+ });
252
+ });
253
+
254
+ /** The seam's FORCE, separately from its vocabulary. */
255
+ function seamForceProblems(text) {
256
+ const problems = [];
257
+ if (!/Every EDIT and every VERDICT MUST declare the sha256 of the source it was built on/.test(text)) {
258
+ problems.push('the version precondition is advisory, not imperative');
259
+ }
260
+ if (!/refusal WITHOUT mutation/.test(text)) {
261
+ problems.push('a mismatch no longer refuses — applying the edit to the newer text is the defect');
262
+ }
263
+ if (!/snapshot of the moment\s*\n?\s*of reading, not of the file/.test(text)) {
264
+ problems.push('the mechanism is gone — without it the duty reads as bookkeeping');
265
+ }
266
+ if (!/node \.claude\/hooks\/check-source-version\.cjs \./.test(text)) {
267
+ problems.push('the deterministic half is not invoked at the seam');
268
+ }
269
+ return problems;
270
+ }
271
+
272
+ describe('the seam still mandates, and every counter agrees', () => {
273
+ const SEAMS = ['templates/.claude/commands/start.md', 'templates/.claude/commands/feature.md'];
274
+
275
+ test('P16 - both WRITING dispatchers carry the obligation, and softening it fires', () => {
276
+ for (const rel of SEAMS) {
277
+ const source = read(rel);
278
+ assert.deepEqual(seamForceProblems(source), [], rel);
279
+
280
+ // The exact softening the backlog record forbids: a promise to re-read instead of a declared
281
+ // version. It is unverifiable by inspection, which is why it must not be able to ship.
282
+ const promise = source.replace(
283
+ 'Every EDIT and every VERDICT MUST declare the sha256 of the source it was built on',
284
+ 'Re-read the source immediately before editing it');
285
+ assert.notEqual(promise, source, rel + ': mutation fixture did not apply — the sentence moved');
286
+ assert.deepEqual(seamForceProblems(promise),
287
+ ['the version precondition is advisory, not imperative'],
288
+ rel + ': replacing the version with a promise must fire EXACTLY the precondition predicate');
289
+
290
+ // Keep the declaration, drop the refusal: the edit gets applied to the newer text anyway,
291
+ // which is the defect with an extra field written next to it.
292
+ const applied = source.replace('refusal WITHOUT mutation', 'a warning worth noting');
293
+ assert.ok(seamForceProblems(applied).some((p) => p.includes('no longer refuses')), rel);
294
+ }
295
+ });
296
+
297
+ test('P17 - it is a hooks component wired to NO event, and every counter agrees', () => {
298
+ const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
299
+ assert.ok(COMPONENTS.hooks.items['check-source-version'],
300
+ 'it must be registered, or init/doctor/verify will not know it');
301
+ const settings = read('templates/.claude/settings.json');
302
+ assert.ok(!settings.includes('check-source-version'),
303
+ 'it must not be wired to an event: this package\'s hooks are non-blocking by contract');
304
+
305
+ const statusline = read('templates/.claude/hooks/statusline.cjs');
306
+ const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
307
+ assert.ok(hooks, 'statusline must declare hooksExpected');
308
+ const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
309
+ assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
310
+ 'the status line would report a phantom missing hook: ' + hooks[1]);
311
+ assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
312
+ 'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
313
+
314
+ const rule = read('templates/.claude/rules/replicate-pipeline.md');
315
+ assert.match(rule, /check-source-version\.cjs/, 'the hook inventory must list it');
316
+ const inventory = rule.match(/\*\*Hooks \((\d+) files/);
317
+ assert.ok(inventory, 'the rule must state the hook count it is inventorying');
318
+ assert.equal(Number(inventory[1]), onDisk.length,
319
+ 'the rule prose and the shipped directory disagree — the exact split this family exists for');
320
+ });
321
+
322
+ test('P18 - the mutation registry carries this feature\'s guards', () => {
323
+ const registry = JSON.parse(read('test/mutation-registry.json'));
324
+ const expected = {
325
+ 'source-version-cannot-check-is-not-clean': 'templates/.claude/hooks/check-source-version.cjs',
326
+ 'source-version-pin-must-still-match': 'templates/.claude/hooks/check-source-version.cjs',
327
+ 'source-version-verdicts-age-like-edits': 'templates/.claude/hooks/check-source-version.cjs',
328
+ 'source-version-seam-declares-not-promises': 'templates/.claude/commands/feature.md',
329
+ };
330
+ for (const [id, file] of Object.entries(expected)) {
331
+ const entry = registry.entries.find((e) => e.id === id);
332
+ assert.ok(entry, 'missing targeted mutation id ' + id);
333
+ assert.equal(entry.file, file, id + ' mutates the wrong surface');
334
+ assert.ok(entry.property && entry.property.length > 40,
335
+ id + ' needs a behavioral property, not a cosmetic label');
336
+ assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
337
+ id + ' needs a real source mutation');
338
+ assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
339
+ id + ' must require at least one failing test');
340
+ assert.equal(read(entry.file).split(entry.mutation.find).length - 1, 1,
341
+ id + ': mutation anchor must occur exactly once');
342
+ }
343
+ });
344
+ });
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ // The deterministic half of the swarm contract. The rule text is prose a model reads — layer 2,
4
+ // violated in silence. This is layer 1, and only for whoever runs it.
5
+ //
6
+ // The load-bearing property is the THIRD exit code, for the same reason it is in check-ports and
7
+ // check-growth-trace: a checker that answers "clean" when it could not look converts an unknown
8
+ // into a reassurance. Here the unknown has a specific and very common cause — a worker whose PID is
9
+ // still alive and whose trace has not appeared yet. That is not delivery and it is not failure; it
10
+ // is "not yet", and reading it as either would be a lie in a different direction each time.
11
+ //
12
+ // These are BEHAVIOUR tests: the real utility, real files, real exit codes.
13
+
14
+ const { test, describe } = require('node:test');
15
+ const assert = require('node:assert/strict');
16
+ const { spawnSync } = require('node:child_process');
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ const PKG = path.resolve(__dirname, '..', '..');
22
+ const TPL = path.join(PKG, 'templates', '.claude');
23
+ const CHECK = path.join(TPL, 'hooks', 'check-swarm-receipts.cjs');
24
+
25
+ /**
26
+ * Build a throwaway run directory, write the traces described, and run the real checker.
27
+ *
28
+ * `traces` maps a unit id to a body, or to `null` for "this unit never wrote anything". The
29
+ * manifest's `launchMs` defaults to one second in the PAST, so a file written by this helper is
30
+ * legitimately post-launch — the freshness boundary is exercised explicitly by its own case.
31
+ */
32
+ function check({ units, traces = {}, launchMs, manifest: override, raw }) {
33
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-receipts-')));
34
+ try {
35
+ const resolved = (units || []).map((u) => ({
36
+ ...u,
37
+ tracePath: u.tracePath === undefined ? path.join(dir, u.workUnitId + '.md') : u.tracePath,
38
+ }));
39
+ for (const unit of resolved) {
40
+ const body = traces[unit.workUnitId];
41
+ if (body === undefined || body === null) continue;
42
+ fs.writeFileSync(unit.tracePath, body);
43
+ }
44
+ const file = path.join(dir, 'receipts.json');
45
+ if (raw !== undefined) fs.writeFileSync(file, raw);
46
+ else {
47
+ const m = override || { runId: 'r1', launchMs: launchMs ?? Date.now() - 1000, units: resolved };
48
+ fs.writeFileSync(file, JSON.stringify(m));
49
+ }
50
+ const r = spawnSync(process.execPath, [CHECK, file], { encoding: 'utf8' });
51
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), dir };
52
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
53
+ }
54
+
55
+ const DONE = 'Implemented the parser and its two tests.\nStatus: completed';
56
+ const FAILED = 'Could not build: the fixture is missing.\nStatus: failed';
57
+
58
+ describe('the swarm-receipt checker answers three questions, and never confuses two of them', () => {
59
+ test('P1 - every unit delivered a terminal completed receipt: exit 0', () => {
60
+ const r = check({
61
+ units: [{ workUnitId: 'api' }, { workUnitId: 'docs' }],
62
+ traces: { api: DONE, docs: DONE },
63
+ });
64
+ assert.equal(r.code, 0, 'two good receipts must pass: ' + r.out);
65
+ assert.match(r.out, /все 2 квитанций/, r.out);
66
+ assert.match(r.out, /ДОСТАВЛЕНА, а не что она верна/,
67
+ 'the receipt proves delivery, never correctness — the limit must be stated');
68
+ });
69
+
70
+ test('P2 - a missing trace from a worker with no liveness claim: exit 1, unit named', () => {
71
+ const r = check({
72
+ units: [{ workUnitId: 'api' }, { workUnitId: 'docs' }],
73
+ traces: { api: DONE },
74
+ });
75
+ assert.equal(r.code, 1, 'a missing receipt must refuse aggregation: ' + r.out);
76
+ assert.match(r.out, /docs \[undelivered\/missing\]/, r.out);
77
+ assert.match(r.out, /ЗАПРЕЩЕНЫ/, 'the refusal must be stated, not implied: ' + r.out);
78
+ });
79
+
80
+ test('P3 - a live PID with no trace is exit 2, a dead PID with no trace is exit 1', () => {
81
+ // The asymmetry is the whole rule: liveness may only EXTEND waiting; it can never deliver.
82
+ const live = check({ units: [{ workUnitId: 'api', pid: process.pid }] });
83
+ assert.equal(live.code, 2, 'a live worker is "not yet", never "clean": ' + live.out);
84
+ assert.match(live.out, /positive-liveness-only/, live.out);
85
+
86
+ // A pid that is certainly gone: spawn a child, wait for it, then reuse its pid.
87
+ const gone = spawnSync(process.execPath, ['-e', 'process.exit(0)']);
88
+ const dead = check({ units: [{ workUnitId: 'api', pid: gone.pid }] });
89
+ assert.equal(dead.code, 1, 'a dead worker with no trace is a delivered failure: ' + dead.out);
90
+ assert.match(dead.out, /dead-worker/, dead.out);
91
+ });
92
+
93
+ test('P4 - empty, whitespace-only, and marker-only bodies are all undelivered', () => {
94
+ for (const [label, body, reason] of [
95
+ ['empty', '', 'empty'],
96
+ ['whitespace', ' \n\t\n', 'empty'],
97
+ ['marker with no work above it', 'Status: completed', 'empty-payload'],
98
+ ]) {
99
+ const r = check({ units: [{ workUnitId: 'api' }], traces: { api: body } });
100
+ assert.equal(r.code, 1, label + ' must not pass: ' + r.out);
101
+ assert.match(r.out, new RegExp(reason), label + ': ' + r.out);
102
+ }
103
+ });
104
+
105
+ test('P5 - a body without the terminal line is PARTIAL, not success', () => {
106
+ const r = check({
107
+ units: [{ workUnitId: 'api' }],
108
+ traces: { api: 'I am half way through writing this file.' },
109
+ });
110
+ assert.equal(r.code, 1, 'a partial write must not read as delivery: ' + r.out);
111
+ assert.match(r.out, /non-terminal/, r.out);
112
+ });
113
+
114
+ test('P6 - a delivered Status: failed blocks the aggregate exactly like a missing one', () => {
115
+ const r = check({ units: [{ workUnitId: 'api' }], traces: { api: FAILED } });
116
+ assert.equal(r.code, 1, 'a delivered failure is still a refusal: ' + r.out);
117
+ assert.match(r.out, /delivered-failure/, r.out);
118
+ });
119
+
120
+ test('P7 - a file that predates the launch is STALE, however good it looks', () => {
121
+ // The single case a naive checker gets wrong: last run's trace, still on disk, fully terminal.
122
+ const r = check({
123
+ units: [{ workUnitId: 'api' }],
124
+ traces: { api: DONE },
125
+ launchMs: Date.now() + 60_000, // launch is in the future relative to the file
126
+ });
127
+ assert.equal(r.code, 1, 'a pre-launch file is not this run\'s evidence: ' + r.out);
128
+ assert.match(r.out, /stale/, r.out);
129
+ });
130
+
131
+ test('P8 - a symlink is not a regular file, even when its target is perfect', () => {
132
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-link-')));
133
+ try {
134
+ const real = path.join(dir, 'real.md');
135
+ const link = path.join(dir, 'api.md');
136
+ fs.writeFileSync(real, DONE);
137
+ fs.symlinkSync(real, link);
138
+ const file = path.join(dir, 'receipts.json');
139
+ fs.writeFileSync(file, JSON.stringify({
140
+ runId: 'r1', launchMs: Date.now() - 1000,
141
+ units: [{ workUnitId: 'api', tracePath: link }],
142
+ }));
143
+ const r = spawnSync(process.execPath, [CHECK, file], { encoding: 'utf8' });
144
+ assert.equal(r.status, 1, 'a symlinked trace must be refused: ' + r.stdout);
145
+ assert.match(r.stdout, /not-regular/, r.stdout);
146
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
147
+ });
148
+
149
+ test('P9 - a malformed ASSIGNMENT is exit 2, because the dispatch was wrong, not the delivery', () => {
150
+ const cases = [
151
+ [{ units: [{ workUnitId: 'api', tracePath: 'relative/path.md' }] }, /assignment/,
152
+ 'a relative TRACE_PATH cannot be verified'],
153
+ [{ units: [{ workUnitId: 'api' }, { workUnitId: 'api' }] }, /WORK_UNIT_ID повторяется/,
154
+ 'one id for two units would let one receipt clear both'],
155
+ [{ manifest: { launchMs: 1, units: [] } }, /ноль рабочих единиц/,
156
+ 'an empty swarm has nothing to prove'],
157
+ [{ manifest: { units: [{ workUnitId: 'a', tracePath: '/tmp/a.md' }] } }, /launchMs/,
158
+ 'without a launch instant, freshness is unknowable'],
159
+ [{ raw: 'not json at all' }, /не разбирается как JSON/, 'a broken manifest is an unknown'],
160
+ [{ raw: '[1,2,3]' }, /не является объектом/, 'an array is not a manifest'],
161
+ ];
162
+ for (const [input, pattern, why] of cases) {
163
+ const r = check(input);
164
+ assert.equal(r.code, 2, why + ' — got ' + r.code + ': ' + r.out);
165
+ assert.match(r.out, /проверка НЕ выполнена/, why + ': ' + r.out);
166
+ assert.match(r.out, pattern, why + ': ' + r.out);
167
+ }
168
+ });
169
+
170
+ test('P9b - two units sharing one TRACE_PATH is exit 2, not a lucky pass', () => {
171
+ // The second writer overwrites the first and BOTH report success — the silent lost update.
172
+ const r = check({
173
+ units: [{ workUnitId: 'a', tracePath: '/tmp/shared-trace.md' },
174
+ { workUnitId: 'b', tracePath: '/tmp/shared-trace.md' }],
175
+ });
176
+ assert.equal(r.code, 2, 'a shared path is a malformed dispatch: ' + r.out);
177
+ assert.match(r.out, /TRACE_PATH повторяется/, r.out);
178
+ });
179
+
180
+ test('P10 - a proven violation OUTRANKS an unresolved unit', () => {
181
+ // One unit is definitively undelivered while another is still live. Exit 1 is the actionable
182
+ // truth: aggregation is already forbidden, and saying "could not check" would be softer than
183
+ // the facts allow.
184
+ const r = check({
185
+ units: [{ workUnitId: 'dead' }, { workUnitId: 'busy', pid: process.pid }],
186
+ traces: {},
187
+ });
188
+ assert.equal(r.code, 1, 'a proven miss must not be downgraded to inconclusive: ' + r.out);
189
+ assert.match(r.out, /dead \[undelivered\/missing\]/, r.out);
190
+ assert.match(r.out, /busy \[waiting\/positive-liveness-only\]/,
191
+ 'the unresolved unit must still be NAMED, not swallowed: ' + r.out);
192
+ });
193
+
194
+ test('P11 - no manifest argument, and a manifest that does not exist, are both exit 2', () => {
195
+ const bare = spawnSync(process.execPath, [CHECK], { encoding: 'utf8' });
196
+ assert.equal(bare.status, 2, 'no argument is not a clean bill: ' + bare.stdout);
197
+ assert.match(bare.stdout, /не передан путь/, bare.stdout);
198
+
199
+ const absent = spawnSync(process.execPath, [CHECK, '/nonexistent/receipts.json'],
200
+ { encoding: 'utf8' });
201
+ assert.equal(absent.status, 2, 'an unreadable manifest is an unknown: ' + absent.stdout);
202
+ assert.match(absent.stdout, /не читается манифест/, absent.stdout);
203
+ });
204
+
205
+ test('P12 - it is a hooks component wired to NO event, and the three counters agree', () => {
206
+ const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
207
+ assert.ok(COMPONENTS.hooks.items['check-swarm-receipts'],
208
+ 'it must be registered, or init/doctor/verify will not know it');
209
+ const settings = fs.readFileSync(path.join(TPL, 'settings.json'), 'utf-8');
210
+ assert.ok(!settings.includes('check-swarm-receipts'),
211
+ 'it must not be wired to an event: this package\'s hooks are non-blocking by contract, so a '
212
+ + 'hook could only print — it could never refuse anything');
213
+ const statusline = fs.readFileSync(path.join(TPL, 'hooks', 'statusline.cjs'), 'utf-8');
214
+ const m = statusline.match(/hooksExpected:\s*(\d+)/);
215
+ assert.ok(m, 'statusline must declare hooksExpected');
216
+ assert.equal(Number(m[1]), Object.keys(COMPONENTS.hooks.items).length,
217
+ 'the status line would report a phantom missing hook: ' + m[1]);
218
+ assert.equal(Number(m[1]), fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs')).length,
219
+ 'the shipped hook FILES must match the declared count — the counter drifted twice before');
220
+ });
221
+
222
+ test('P13 - MUTATION: turning the third exit code into a pass is caught', () => {
223
+ // The registered mutation in test/mutation-registry.json flips cannotCheck's exit(2) to exit(0).
224
+ // Assert here that at least one real input DEPENDS on that distinction, so the mutant has a
225
+ // killer: without this, the registry entry would name a property nothing proves.
226
+ const source = fs.readFileSync(CHECK, 'utf-8');
227
+ assert.match(source, /process\.exit\(2\);/, 'cannotCheck must exit 2');
228
+ const live = check({ units: [{ workUnitId: 'api', pid: process.pid }] });
229
+ assert.equal(live.code, 2, 'the live-worker case is the killer input for that mutant');
230
+ });
231
+ });