@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,388 @@
1
+ 'use strict';
2
+
3
+ // The deterministic half of «владелец назначается в момент разреза». There is NO new always-loaded
4
+ // rule for this feature: the corpus budget is spent (measured 2026-09-01) and the owner refused to
5
+ // raise the threshold, so the doctrine lives in the checker's header, which is never loaded.
6
+ //
7
+ // WHAT IS ALREADY CLOSED, AND IS NOT RE-OPENED HERE. The general «two writers, one file» half is
8
+ // settled twice in this package — the shared-store principle, and the ownership split BY ORIGIN
9
+ // between /replicate and /start (tests/unit/pipeline-file-ownership.test.js). This suite covers the
10
+ // half neither of them reaches: the LIFECYCLE of ownership, i.e. what happens when a file APPEARS
11
+ // during the run.
12
+ //
13
+ // THE DEFECT. A file born by SPLITTING a large file inherits an owner from nobody — ownership does
14
+ // not travel by itself. The field case: a coordinator wrote into a fresh Architecture-OPS.md a
15
+ // paragraph about a permission the owner of the SOURCE file was revoking in the neighbouring
16
+ // document in the same minutes. Both authors internally consistent, both files individually
17
+ // correct, the two documents contradicting each other, and nothing in the run able to see it.
18
+ //
19
+ // The second measured signature, this repository 2026-09-01: two swarms in their own worktrees
20
+ // produced 23 and 17 merge conflicts, while the swarm in the MAIN tree produced none — not from
21
+ // better coordination, but because its second writer edited a live file with no merge to arbitrate.
22
+ // A silent overwrite leaves no conflict to count.
23
+ //
24
+ // HONEST BOUNDARY, asserted rather than merely written (P17): this is a check of the DECLARATION.
25
+ // A coordinator editing somebody else's file outside the protocol leaves the plan unchanged and
26
+ // this check green. There is no layer-1 check of the BEHAVIOUR without write attribution.
27
+
28
+ const { test, describe } = require('node:test');
29
+ const assert = require('node:assert/strict');
30
+ const { spawnSync } = require('node:child_process');
31
+ const fs = require('node:fs');
32
+ const os = require('node:os');
33
+ const path = require('node:path');
34
+
35
+ const PKG = path.resolve(__dirname, '..', '..');
36
+ const TPL = path.join(PKG, 'templates', '.claude');
37
+ const CHECK = path.join(TPL, 'hooks', 'check-file-ownership.cjs');
38
+ const PLAN = 'docs/dispatch-plan.md';
39
+
40
+ const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
41
+
42
+ /** Build a throwaway project and run the REAL checker over it. */
43
+ function check(files) {
44
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-own-')));
45
+ try {
46
+ for (const [rel, body] of Object.entries(files || {})) {
47
+ const abs = path.join(dir, rel);
48
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
49
+ fs.writeFileSync(abs, body);
50
+ }
51
+ const r = spawnSync(process.execPath, [CHECK, dir], { encoding: 'utf8' });
52
+ return { code: r.status, out: (r.stdout || '') + (r.stderr || '') };
53
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
54
+ }
55
+
56
+ /**
57
+ * A dispatch plan in whatever state the case needs.
58
+ *
59
+ * Defaults are the HEALTHY ones so that every case stays about the single thing it changes; a case
60
+ * that wants silence passes `null` for that field, and silence is asserted as its own outcome.
61
+ */
62
+ function plan({
63
+ writes = 'да', coord = 'да', splits = 'да', run = 'ВЫПОЛНЕНА', reason = null,
64
+ units = [['packages/api', 'src/api/**'], ['packages/web', 'src/web/**']],
65
+ owned = [
66
+ ['docs/Architecture.md', 'packages/api'],
67
+ ['src/web/index.ts', 'packages/web'],
68
+ ['docs/Architecture-OPS.md', 'координатор'],
69
+ ],
70
+ splitRows = [['docs/Architecture-OPS.md', 'docs/Architecture.md', 'координатор']],
71
+ } = {}) {
72
+ const line = (label, value) => (value === null || value === undefined ? '' : '**' + label + ':** ' + value + '\n');
73
+ const table = (head, body) => '\n## ' + head + '\n\n'
74
+ + '| ' + (head === 'Единицы' ? 'Единица | Что пишет'
75
+ : head === 'Владение' ? 'Файл | Владелец' : 'Новый файл | Разрезан из | Владелец') + ' |\n'
76
+ + '|' + '---|'.repeat(head === 'События разреза' ? 3 : 2) + '\n'
77
+ + body.map((r) => '| ' + r.join(' | ') + ' |').join('\n') + '\n';
78
+ return '# Dispatch plan\n\n'
79
+ + line('Пишущий фан-аут', writes)
80
+ + line('Координатор пишет', coord)
81
+ + line('Разрезы файлов', splits)
82
+ + line('Проверка владения', run)
83
+ + line('Причина', reason)
84
+ + table('Единицы', units)
85
+ + table('Владение', owned)
86
+ + table('События разреза', splitRows);
87
+ }
88
+
89
+ describe('владение назначается в момент разреза — the deterministic half', () => {
90
+ test('P1 - a total, unambiguous plan with an owned split is CLEAN', () => {
91
+ const r = check({ [PLAN]: plan() });
92
+ assert.equal(r.code, 0, r.out);
93
+ assert.match(r.out, /✅/);
94
+ // The boundary is part of the verdict, not a footnote elsewhere.
95
+ assert.match(r.out, /проверка ДЕКЛАРАЦИИ/, r.out);
96
+ });
97
+
98
+ test('P2 - no plan at all is NOT clean', () => {
99
+ const r = check({});
100
+ assert.equal(r.code, 2, r.out);
101
+ assert.match(r.out, /нет файла docs\/dispatch-plan\.md/);
102
+ assert.match(r.out, /НЕ ЗАДАВАЛСЯ/, '"nobody asked" is not "there is one writer"');
103
+ });
104
+
105
+ test('P3 - a read-only fan-out owns nothing, and that is 2 rather than 0', () => {
106
+ const r = check({ [PLAN]: plan({ writes: 'нет' }) });
107
+ assert.equal(r.code, 2, r.out);
108
+ assert.doesNotMatch(r.out, /✅/);
109
+ });
110
+
111
+ test('P4 - an unrecognised closed value stops the check instead of guessing', () => {
112
+ for (const field of [{ writes: 'возможно' }, { coord: 'иногда' }, { splits: 'частично' }]) {
113
+ const r = check({ [PLAN]: plan(field) });
114
+ assert.equal(r.code, 2, JSON.stringify(field) + ' → ' + r.out);
115
+ assert.match(r.out, /нераспознанное значение/);
116
+ }
117
+ });
118
+
119
+ test('P5 - «НЕ ВЫПОЛНЕНА» needs a reason from the closed list, and then it is honest', () => {
120
+ const noReason = check({ [PLAN]: plan({ run: 'НЕ ВЫПОЛНЕНА' }) });
121
+ assert.equal(noReason.code, 2, noReason.out);
122
+ assert.match(noReason.out, /без строки `\*\*Причина:\*\*`/);
123
+
124
+ const freeText = check({ [PLAN]: plan({ run: 'НЕ ВЫПОЛНЕНА', reason: 'потом' }) });
125
+ assert.equal(freeText.code, 2, freeText.out);
126
+ assert.match(freeText.out, /не из закрытого списка/);
127
+
128
+ const named = check({ [PLAN]: plan({ run: 'НЕ ВЫПОЛНЕНА', reason: 'решение-отложено' }) });
129
+ assert.equal(named.code, 2, named.out);
130
+ assert.match(named.out, /решение-отложено/);
131
+ assert.doesNotMatch(named.out, /✅/);
132
+ });
133
+
134
+ test('P6 - THE fixture: one file assigned to a unit AND to the coordinator is PROVEN', () => {
135
+ const r = check({ [PLAN]: plan({
136
+ owned: [
137
+ ['docs/Architecture.md', 'packages/api'],
138
+ ['docs/Architecture.md', 'координатор'],
139
+ ['src/web/index.ts', 'packages/web'],
140
+ ['docs/Architecture-OPS.md', 'координатор'],
141
+ ],
142
+ }) });
143
+ assert.equal(r.code, 1, r.out);
144
+ assert.match(r.out, /у одного файла два владельца/);
145
+ assert.match(r.out, /docs\/Architecture\.md/, 'the file must be NAMED');
146
+ // The reason, in words: atomicity does not treat a lost update.
147
+ assert.match(r.out, /Атомарная запись этого не лечит/);
148
+ });
149
+
150
+ test('P7 - and the same plan with ONE owner for that file passes', () => {
151
+ assert.equal(check({ [PLAN]: plan() }).code, 0);
152
+ });
153
+
154
+ test('P8 - an owner nobody dispatched is a file with no writer that reads as owned', () => {
155
+ const r = check({ [PLAN]: plan({
156
+ owned: [
157
+ ['docs/Architecture.md', 'packages/api'],
158
+ ['src/web/index.ts', 'packages/web'],
159
+ ['docs/Architecture-OPS.md', 'координатор'],
160
+ ['docs/Ops.md', 'packages/mobile'],
161
+ ],
162
+ }) });
163
+ assert.equal(r.code, 1, r.out);
164
+ assert.match(r.out, /не назван среди диспатчируемых единиц/);
165
+ assert.match(r.out, /packages\/mobile/);
166
+ });
167
+
168
+ test('P9 - totality runs BOTH ways: a dispatched unit that owns nothing fails', () => {
169
+ // The direction a table naturally forgets. The unit writes anyway; only its path is undeclared.
170
+ const r = check({ [PLAN]: plan({
171
+ owned: [['docs/Architecture.md', 'packages/api'], ['docs/Architecture-OPS.md', 'координатор']],
172
+ }) });
173
+ assert.equal(r.code, 1, r.out);
174
+ assert.match(r.out, /не владеет ни одним файлом/);
175
+ assert.match(r.out, /packages\/web/);
176
+ });
177
+
178
+ test('P10 - the coordinator must answer for ITSELF, in both directions', () => {
179
+ // Declared writing, absent from the table: the table is total for everyone but the author of
180
+ // the measured defect.
181
+ const missing = check({ [PLAN]: plan({
182
+ owned: [['docs/Architecture.md', 'packages/api'], ['src/web/index.ts', 'packages/web']],
183
+ splits: 'нет', splitRows: [],
184
+ }) });
185
+ assert.equal(missing.code, 1, missing.out);
186
+ assert.match(missing.out, /координатор объявлен пишущим, но не владеет/);
187
+
188
+ // Declared non-writing and owning files anyway: the reader believes the declaration, and the
189
+ // declaration is the assumption under which a second writer stays unnoticed.
190
+ const contradicted = check({ [PLAN]: plan({ coord: 'нет' }) });
191
+ assert.equal(contradicted.code, 1, contradicted.out);
192
+ assert.match(contradicted.out, /объявлен непишущим, но владеет файлами/);
193
+ });
194
+
195
+ test('P11 - a coordinator that genuinely writes nothing is legitimate and passes', () => {
196
+ // An escape that exists, or the mandatory row becomes a lie people write to get green.
197
+ const r = check({ [PLAN]: plan({
198
+ coord: 'нет',
199
+ owned: [['docs/Architecture.md', 'packages/api'], ['src/web/index.ts', 'packages/web'],
200
+ ['docs/Architecture-OPS.md', 'packages/api']],
201
+ splitRows: [['docs/Architecture-OPS.md', 'docs/Architecture.md', 'packages/api']],
202
+ }) });
203
+ assert.equal(r.code, 0, r.out);
204
+ assert.match(r.out, /объявлен непишущим/);
205
+ });
206
+
207
+ test('P12 - THE fixture: a split that produced a file with NO owner is PROVEN', () => {
208
+ const r = check({ [PLAN]: plan({
209
+ owned: [['docs/Architecture.md', 'packages/api'], ['src/web/index.ts', 'packages/web'],
210
+ ['docs/Architecture-OPS.md', 'координатор']],
211
+ splitRows: [['docs/Architecture-OPS.md', 'docs/Architecture.md', '—']],
212
+ }) });
213
+ assert.equal(r.code, 1, r.out);
214
+ assert.match(r.out, /создан разрезом БЕЗ владельца/);
215
+ assert.match(r.out, /docs\/Architecture-OPS\.md/);
216
+ // The load-bearing sentence: ownership does not travel by itself.
217
+ assert.match(r.out, /не переносится само/);
218
+ });
219
+
220
+ test('P13 - and the SAME split with an explicit owner passes', () => {
221
+ assert.equal(check({ [PLAN]: plan() }).code, 0);
222
+ });
223
+
224
+ test('P14 - a split whose owner contradicts the ownership table fails', () => {
225
+ // Two declarations about one file. Which one applies depends on which the worker read.
226
+ const r = check({ [PLAN]: plan({
227
+ splitRows: [['docs/Architecture-OPS.md', 'docs/Architecture.md', 'packages/api']],
228
+ }) });
229
+ assert.equal(r.code, 1, r.out);
230
+ assert.match(r.out, /противоречит таблице владения/);
231
+ });
232
+
233
+ test('P15 - a split-born file absent from the ownership table fails', () => {
234
+ const r = check({ [PLAN]: plan({
235
+ splitRows: [['docs/Architecture-OPS.md', 'docs/Architecture.md', 'координатор'],
236
+ ['docs/Architecture-NET.md', 'docs/Architecture.md', 'координатор']],
237
+ }) });
238
+ assert.equal(r.code, 1, r.out);
239
+ assert.match(r.out, /не попал в таблицу владения/);
240
+ assert.match(r.out, /Architecture-NET/);
241
+ });
242
+
243
+ test('P16 - an absent split section must be DECLARED, never inferred', () => {
244
+ // «Разрезов не было» and «про разрезы забыли» must not be written the same way. A declared
245
+ // «да» with an empty table is a proven omission; a declared «нет» with rows is a contradiction
246
+ // the checker cannot resolve, so it refuses rather than picking one.
247
+ const declaredEmpty = check({ [PLAN]: plan({ splits: 'да', splitRows: [] }) });
248
+ assert.equal(declaredEmpty.code, 1, declaredEmpty.out);
249
+ assert.match(declaredEmpty.out, /ни один не назван/);
250
+
251
+ const contradicted = check({ [PLAN]: plan({ splits: 'нет' }) });
252
+ assert.equal(contradicted.code, 2, contradicted.out);
253
+ assert.match(contradicted.out, /таблица разрезов не пуста/);
254
+
255
+ const honestlyNone = check({ [PLAN]: plan({
256
+ splits: 'нет', splitRows: [],
257
+ owned: [['docs/Architecture.md', 'packages/api'], ['src/web/index.ts', 'packages/web'],
258
+ ['README.md', 'координатор']],
259
+ }) });
260
+ assert.equal(honestlyNone.code, 0, honestlyNone.out);
261
+ });
262
+
263
+ test('P17 - rows are read PER SECTION, so a unit is never mistaken for a file', () => {
264
+ // Three markdown tables live in one document and two have look-alike first columns. A parser
265
+ // that scanned the whole file would read `packages/api` from the units table as a FILE with no
266
+ // owner — an answer to a question nobody asked, delivered as a defect. The healthy plan passing
267
+ // at all is the assertion; it contains all three tables.
268
+ const r = check({ [PLAN]: plan() });
269
+ assert.equal(r.code, 0, r.out);
270
+ assert.match(r.out, /3 файл\(ов\), 2 единиц\(ы\)/, r.out);
271
+ });
272
+
273
+ test('P18 - no non-zero exit ever prints the clean marker', () => {
274
+ const cases = [
275
+ {},
276
+ { [PLAN]: plan({ writes: 'нет' }) },
277
+ { [PLAN]: plan({ owned: [] }) },
278
+ { [PLAN]: plan({ units: [] }) },
279
+ { [PLAN]: plan({ splitRows: [['docs/Architecture-OPS.md', 'docs/Architecture.md', '']] }) },
280
+ ];
281
+ for (const files of cases) {
282
+ const r = check(files);
283
+ assert.notEqual(r.code, 0, JSON.stringify(Object.keys(files)));
284
+ assert.doesNotMatch(r.out, /✅/, r.out);
285
+ }
286
+ });
287
+ });
288
+
289
+ /**
290
+ * The seam's FORCE, separately from its vocabulary. A test that only checks that the words
291
+ * «ownership» and «check-file-ownership.cjs» appear would pass a paragraph softened into a
292
+ * suggestion — this package has MEASURED that exact survival (commit 37916bd3).
293
+ */
294
+ function seamForceProblems(text) {
295
+ const problems = [];
296
+ if (!/The same plan MUST assign OWNERSHIP/.test(text)) {
297
+ problems.push('the ownership duty is advisory, not imperative');
298
+ }
299
+ if (!/exactly one writer per file, the coordinator included/.test(text)) {
300
+ problems.push('the coordinator is not named as a writer, which is the author of the field defect');
301
+ }
302
+ if (!/born by SPLITTING another gets its owner AT CREATION/.test(text)) {
303
+ problems.push('the lifecycle half is gone — this is the only half the two shipped rules do not cover');
304
+ }
305
+ if (!/ownership never travels by itself/.test(text)) {
306
+ problems.push('the mechanism is gone — without it the duty reads as bookkeeping');
307
+ }
308
+ if (!/node \.claude\/hooks\/check-file-ownership\.cjs \./.test(text)) {
309
+ problems.push('the deterministic half is not invoked at the seam');
310
+ }
311
+ return problems;
312
+ }
313
+
314
+ describe('the seam still mandates, and every counter agrees', () => {
315
+ const SEAMS = ['templates/.claude/commands/start.md', 'templates/.claude/commands/feature.md'];
316
+
317
+ test('P19 - both WRITING dispatchers carry the obligation, and softening it fires', () => {
318
+ for (const rel of SEAMS) {
319
+ const source = read(rel);
320
+ assert.deepEqual(seamForceProblems(source), [], rel);
321
+
322
+ const softened = source.replace('The same plan MUST assign OWNERSHIP',
323
+ 'The plan may also record ownership');
324
+ assert.notEqual(softened, source, rel + ': mutation fixture did not apply — the sentence moved');
325
+ assert.deepEqual(seamForceProblems(softened), ['the ownership duty is advisory, not imperative'],
326
+ rel + ': softening the duty must fire EXACTLY the mandate predicate');
327
+
328
+ // Delete the lifecycle half and keep the duty: what remains is the general single-writer rule
329
+ // this package already shipped twice, and re-shipping it closes nothing new.
330
+ const noLifecycle = source.replace('born by SPLITTING another gets its owner AT CREATION',
331
+ 'created during the run should also be assigned');
332
+ assert.ok(seamForceProblems(noLifecycle).some((p) => p.includes('lifecycle half')), rel);
333
+
334
+ // Drop the coordinator: a table total for everyone except the author of the defect.
335
+ const noCoordinator = source.replace('exactly one writer per file, the coordinator included',
336
+ 'one writer per file');
337
+ assert.ok(seamForceProblems(noCoordinator).some((p) => p.includes('coordinator')), rel);
338
+ }
339
+ });
340
+
341
+ test('P20 - it is a hooks component wired to NO event, and every counter agrees', () => {
342
+ const { COMPONENTS } = require(path.join(PKG, 'src', 'utils.js'));
343
+ assert.ok(COMPONENTS.hooks.items['check-file-ownership'],
344
+ 'it must be registered, or init/doctor/verify will not know it');
345
+ const settings = read('templates/.claude/settings.json');
346
+ assert.ok(!settings.includes('check-file-ownership'),
347
+ 'it must not be wired to an event: this package\'s hooks are non-blocking by contract');
348
+
349
+ const statusline = read('templates/.claude/hooks/statusline.cjs');
350
+ const hooks = statusline.match(/hooksExpected:\s*(\d+)/);
351
+ assert.ok(hooks, 'statusline must declare hooksExpected');
352
+ const onDisk = fs.readdirSync(path.join(TPL, 'hooks')).filter((f) => f.endsWith('.cjs'));
353
+ assert.equal(Number(hooks[1]), Object.keys(COMPONENTS.hooks.items).length,
354
+ 'the status line would report a phantom missing hook: ' + hooks[1]);
355
+ assert.equal(onDisk.length, Object.keys(COMPONENTS.hooks.items).length,
356
+ 'the shipped directory and the declared contract disagree: ' + onDisk.join(', '));
357
+
358
+ const rule = read('templates/.claude/rules/replicate-pipeline.md');
359
+ assert.match(rule, /check-file-ownership\.cjs/, 'the hook inventory must list it');
360
+ const inventory = rule.match(/\*\*Hooks \((\d+) files/);
361
+ assert.ok(inventory, 'the rule must state the hook count it is inventorying');
362
+ assert.equal(Number(inventory[1]), onDisk.length,
363
+ 'the rule prose and the shipped directory disagree — the exact split this family exists for');
364
+ });
365
+
366
+ test('P21 - the mutation registry carries this feature\'s guards', () => {
367
+ const registry = JSON.parse(read('test/mutation-registry.json'));
368
+ const expected = {
369
+ 'ownership-cannot-check-is-not-clean': 'templates/.claude/hooks/check-file-ownership.cjs',
370
+ 'ownership-split-born-file-needs-an-owner': 'templates/.claude/hooks/check-file-ownership.cjs',
371
+ 'ownership-coordinator-is-a-writer-too': 'templates/.claude/hooks/check-file-ownership.cjs',
372
+ 'ownership-seam-lifecycle-half-is-required': 'templates/.claude/commands/feature.md',
373
+ };
374
+ for (const [id, file] of Object.entries(expected)) {
375
+ const entry = registry.entries.find((e) => e.id === id);
376
+ assert.ok(entry, 'missing targeted mutation id ' + id);
377
+ assert.equal(entry.file, file, id + ' mutates the wrong surface');
378
+ assert.ok(entry.property && entry.property.length > 40,
379
+ id + ' needs a behavioral property, not a cosmetic label');
380
+ assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
381
+ id + ' needs a real source mutation');
382
+ assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
383
+ id + ' must require at least one failing test');
384
+ assert.equal(read(entry.file).split(entry.mutation.find).length - 1, 1,
385
+ id + ': mutation anchor must occur exactly once');
386
+ }
387
+ });
388
+ });