@dzhechkov/p-replicator 1.10.4 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +434 -24
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, describe } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
11
|
+
const TPL = path.join(PKG, 'templates', '.claude');
|
|
12
|
+
const WRITER = path.join(TPL, 'hooks', 'write-insight.cjs');
|
|
13
|
+
const HARVEST = path.join(TPL, 'commands', 'harvest.md');
|
|
14
|
+
const CLI = path.join(PKG, 'bin', 'cli.js');
|
|
15
|
+
|
|
16
|
+
function tempProject(prefix = 'p-rep-insight-') {
|
|
17
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cleanup(root) {
|
|
21
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function payload(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
date: '2026-08-30',
|
|
27
|
+
title: 'Anchor writes at the project root',
|
|
28
|
+
tags: ['hooks', 'project-root'],
|
|
29
|
+
problem: 'A changed working directory hid the project carrier.',
|
|
30
|
+
solution: 'Resolve the carrier from the explicit project root.',
|
|
31
|
+
references: ['templates/.claude/hooks/session-insights.cjs:19'],
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runWriter(root, input, options = {}) {
|
|
37
|
+
const env = { ...process.env, PATH: options.path === undefined ? '' : options.path };
|
|
38
|
+
if (options.withEnv === false) delete env.CLAUDE_PROJECT_DIR;
|
|
39
|
+
else env.CLAUDE_PROJECT_DIR = root;
|
|
40
|
+
const writer = options.writer || WRITER;
|
|
41
|
+
return spawnSync(process.execPath, [writer], {
|
|
42
|
+
cwd: options.cwd || root,
|
|
43
|
+
env,
|
|
44
|
+
input: typeof input === 'string' ? input : JSON.stringify(input),
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function absentDz() {
|
|
50
|
+
return { status: null, error: { code: 'ENOENT' }, stdout: '', stderr: '' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function receipt(result) {
|
|
54
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
55
|
+
return JSON.parse(result.stdout);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireWriter() {
|
|
59
|
+
assert.ok(fs.existsSync(WRITER),
|
|
60
|
+
'the packaged write-insight.cjs boundary does not exist yet');
|
|
61
|
+
delete require.cache[require.resolve(WRITER)];
|
|
62
|
+
return require(WRITER);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('PR-022 harvest insight persistence', () => {
|
|
66
|
+
test('P1 - quick and full reject a real writer failure while marker stays non-writing', () => {
|
|
67
|
+
const command = fs.readFileSync(HARVEST, 'utf8');
|
|
68
|
+
assert.match(command, /REQUIRED INSIGHT PERSISTENCE GATE/,
|
|
69
|
+
'quick/full need one named mandatory gate before their completion report');
|
|
70
|
+
assert.match(command, /write-insight\.cjs/,
|
|
71
|
+
'the command must invoke the executable persistence boundary');
|
|
72
|
+
assert.match(command, /created.*appended.*duplicate/s,
|
|
73
|
+
'only the three canonical receipts may authorize completion');
|
|
74
|
+
assert.match(command, /writer exits non-zero.*MUST NOT\s+report.*completed/is,
|
|
75
|
+
'a real persistence failure must stop the ordinary completion report');
|
|
76
|
+
assert.match(command, /marker.*does not invoke.*write-insight/is,
|
|
77
|
+
'marker requests future work and must remain non-writing');
|
|
78
|
+
assert.match(command, /no candidate.*write nothing.*capture remains\s+incomplete/is,
|
|
79
|
+
'a findings-free run must stay non-writing and may not fabricate completion');
|
|
80
|
+
|
|
81
|
+
const root = tempProject();
|
|
82
|
+
try {
|
|
83
|
+
const failed = runWriter(root, '{not-json');
|
|
84
|
+
assert.notEqual(failed.status, 0, 'malformed JSON must exercise a real writer failure');
|
|
85
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'insights')), false,
|
|
86
|
+
'a failed candidate must not manufacture the missing carrier');
|
|
87
|
+
} finally { cleanup(root); }
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('P2 - writer creates appends preserves legacy bytes and deduplicates exact payloads', () => {
|
|
91
|
+
const { writeInsight } = requireWriter();
|
|
92
|
+
const root = tempProject();
|
|
93
|
+
try {
|
|
94
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
95
|
+
const first = writeInsight(root, payload(), { runner: absentDz });
|
|
96
|
+
assert.equal(first.status, 'created');
|
|
97
|
+
assert.equal(first.entryCount, 1);
|
|
98
|
+
const created = fs.readFileSync(index, 'utf8');
|
|
99
|
+
assert.match(created, /^## 2026-08-30 — Anchor writes at the project root/m);
|
|
100
|
+
assert.match(created, /<!-- insight-id: sha256:[a-f0-9]{64} -->/);
|
|
101
|
+
|
|
102
|
+
const distinct = payload({ title: 'Unicode path stays intact', tags: ['utf-8'],
|
|
103
|
+
problem: 'Путь с пробелами ломал запись.', solution: 'Передавать JSON через stdin.',
|
|
104
|
+
references: [] });
|
|
105
|
+
const appended = writeInsight(root, distinct, { runner: absentDz });
|
|
106
|
+
assert.equal(appended.status, 'appended');
|
|
107
|
+
assert.ok(fs.readFileSync(index, 'utf8').startsWith(created),
|
|
108
|
+
'append must preserve every already-committed byte as a prefix');
|
|
109
|
+
|
|
110
|
+
const beforeReplay = fs.readFileSync(index);
|
|
111
|
+
const duplicate = writeInsight(root, { ...distinct, date: '2026-08-31' }, { runner: absentDz });
|
|
112
|
+
assert.equal(duplicate.status, 'duplicate', 'date is not part of semantic identity');
|
|
113
|
+
assert.deepEqual(fs.readFileSync(index), beforeReplay,
|
|
114
|
+
'an exact normalized replay must perform no carrier write');
|
|
115
|
+
|
|
116
|
+
const legacyEntry = [
|
|
117
|
+
'## 2026-08-29 — Manually captured legacy record',
|
|
118
|
+
'',
|
|
119
|
+
'**Tags:** manual',
|
|
120
|
+
'',
|
|
121
|
+
'**Problem:**',
|
|
122
|
+
'This entry predates writer identity markers.',
|
|
123
|
+
'',
|
|
124
|
+
'**Solution:**',
|
|
125
|
+
'Keep every legacy byte intact.',
|
|
126
|
+
'',
|
|
127
|
+
'---',
|
|
128
|
+
'',
|
|
129
|
+
].join('\n');
|
|
130
|
+
fs.appendFileSync(index, legacyEntry);
|
|
131
|
+
const legacyPrefix = fs.readFileSync(index);
|
|
132
|
+
|
|
133
|
+
const sameDateDistinct = writeInsight(root,
|
|
134
|
+
{ ...distinct, solution: 'Use structured stdin.' }, { runner: absentDz });
|
|
135
|
+
assert.equal(sameDateDistinct.status, 'appended',
|
|
136
|
+
'same-date payloads remain distinct when a semantic field changes');
|
|
137
|
+
assert.ok(fs.readFileSync(index).subarray(0, legacyPrefix.length).equals(legacyPrefix),
|
|
138
|
+
'writer append must preserve a marker-free /myinsights legacy record byte-for-byte');
|
|
139
|
+
assert.equal(sameDateDistinct.entryCount, 4);
|
|
140
|
+
} finally { cleanup(root); }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('P3 - CLI validates before mutation and stays project-root anchored', () => {
|
|
144
|
+
requireWriter();
|
|
145
|
+
const spaced = tempProject('p rep insight utf8 ');
|
|
146
|
+
try {
|
|
147
|
+
const nested = path.join(spaced, 'nested', 'work');
|
|
148
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
149
|
+
const ok = runWriter(spaced, payload({ title: 'Путь с пробелами',
|
|
150
|
+
problem: 'Текущий каталог изменился.', solution: 'Якорь остаётся у корня.' }), { cwd: nested });
|
|
151
|
+
assert.equal(receipt(ok).status, 'created');
|
|
152
|
+
assert.ok(fs.readFileSync(path.join(spaced, '.claude', 'insights', 'index.md'), 'utf8')
|
|
153
|
+
.includes('Путь с пробелами'));
|
|
154
|
+
assert.equal(fs.existsSync(path.join(nested, '.claude')), false,
|
|
155
|
+
'cwd drift must not create a second project tree');
|
|
156
|
+
} finally { cleanup(spaced); }
|
|
157
|
+
|
|
158
|
+
const invalidCases = [
|
|
159
|
+
['invalid JSON', '{'],
|
|
160
|
+
['invalid calendar date', payload({ date: '2026-02-30' })],
|
|
161
|
+
['blank required field', payload({ problem: ' ' })],
|
|
162
|
+
['non-string array member', payload({ tags: ['ok', 3] })],
|
|
163
|
+
['missing field', (() => { const p = payload(); delete p.references; return p; })()],
|
|
164
|
+
];
|
|
165
|
+
for (const [name, input] of invalidCases) {
|
|
166
|
+
const root = tempProject();
|
|
167
|
+
try {
|
|
168
|
+
const result = runWriter(root, input);
|
|
169
|
+
assert.notEqual(result.status, 0, name + ' must be rejected');
|
|
170
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'insights')), false,
|
|
171
|
+
name + ' mutated the carrier before validation completed');
|
|
172
|
+
} finally { cleanup(root); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const broken = tempProject();
|
|
176
|
+
try {
|
|
177
|
+
fs.writeFileSync(path.join(broken, '.claude'), 'not a directory\n');
|
|
178
|
+
const result = runWriter(broken, payload());
|
|
179
|
+
assert.notEqual(result.status, 0, 'an unavailable carrier must fail loudly');
|
|
180
|
+
assert.equal(fs.readFileSync(path.join(broken, '.claude'), 'utf8'), 'not a directory\n');
|
|
181
|
+
} finally { cleanup(broken); }
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('P4 - installed writer output remains readable by the hook and doctor', () => {
|
|
185
|
+
requireWriter();
|
|
186
|
+
const root = tempProject();
|
|
187
|
+
try {
|
|
188
|
+
const init = spawnSync(process.execPath, [CLI, 'init'], { cwd: root, encoding: 'utf8' });
|
|
189
|
+
assert.equal(init.status, 0, init.stderr || init.stdout);
|
|
190
|
+
assert.equal(fs.existsSync(path.join(root, '.claude', 'insights')), false,
|
|
191
|
+
'init must preserve the legitimate pre-first-write missing state');
|
|
192
|
+
|
|
193
|
+
const installedWriter = path.join(root, '.claude', 'hooks', 'write-insight.cjs');
|
|
194
|
+
const written = runWriter(root, payload(), { writer: installedWriter });
|
|
195
|
+
assert.equal(receipt(written).status, 'created');
|
|
196
|
+
|
|
197
|
+
const hook = spawnSync(process.execPath,
|
|
198
|
+
[path.join(root, '.claude', 'hooks', 'session-insights.cjs')], {
|
|
199
|
+
cwd: path.join(root, '.claude'),
|
|
200
|
+
env: { ...process.env, PATH: '', CLAUDE_PROJECT_DIR: root },
|
|
201
|
+
input: JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt: 'anchor writes' }),
|
|
202
|
+
encoding: 'utf8',
|
|
203
|
+
});
|
|
204
|
+
assert.equal(hook.status, 0, hook.stderr);
|
|
205
|
+
assert.match(hook.stdout, /Anchor writes at the project root/);
|
|
206
|
+
|
|
207
|
+
const doctor = spawnSync(process.execPath, [CLI, 'doctor'], { cwd: root, encoding: 'utf8' });
|
|
208
|
+
assert.equal(doctor.status, 0, doctor.stderr || doctor.stdout);
|
|
209
|
+
assert.match(doctor.stdout, /insights carrier.*1 entr/i);
|
|
210
|
+
} finally { cleanup(root); }
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('P16 - Markdown success dominates absent failed and timed-out teach', () => {
|
|
214
|
+
const { writeInsight } = requireWriter();
|
|
215
|
+
const cases = [
|
|
216
|
+
['absent', { status: null, error: { code: 'ENOENT' }, stdout: '', stderr: '' }, 'absent'],
|
|
217
|
+
['failed', { status: 9, stdout: '', stderr: 'private stderr' }, 'failed'],
|
|
218
|
+
['timeout', { status: null, error: { code: 'ETIMEDOUT' }, stdout: '', stderr: '' }, 'failed'],
|
|
219
|
+
];
|
|
220
|
+
for (const [name, processResult, expectedState] of cases) {
|
|
221
|
+
const root = tempProject(`p-rep-teach-${name}-`);
|
|
222
|
+
let importPath;
|
|
223
|
+
try {
|
|
224
|
+
const result = writeInsight(root, payload({ title: `Markdown survives ${name} teach` }), {
|
|
225
|
+
runner(file, args, options) {
|
|
226
|
+
assert.equal(file, 'dz');
|
|
227
|
+
assert.deepEqual(args.slice(0, 2), ['teach', '--from-json']);
|
|
228
|
+
assert.equal(args[args.length - 2], '--project');
|
|
229
|
+
assert.equal(args[args.length - 1], root);
|
|
230
|
+
assert.equal(options.cwd, root);
|
|
231
|
+
assert.equal(options.timeout, 1500);
|
|
232
|
+
assert.equal(options.maxBuffer, 1024 * 1024);
|
|
233
|
+
assert.equal(options.shell, false);
|
|
234
|
+
importPath = args[2];
|
|
235
|
+
assert.equal(fs.statSync(importPath).mode & 0o777, 0o600);
|
|
236
|
+
const imported = JSON.parse(fs.readFileSync(importPath, 'utf8'));
|
|
237
|
+
assert.equal(imported.length, 1);
|
|
238
|
+
assert.equal(imported[0].domain, 'p-replicator-insights');
|
|
239
|
+
return processResult;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(result.status, 'created');
|
|
243
|
+
assert.equal(result.teach.state, expectedState);
|
|
244
|
+
assert.match(fs.readFileSync(path.join(root, '.claude', 'insights', 'index.md'), 'utf8'),
|
|
245
|
+
new RegExp(`Markdown survives ${name} teach`));
|
|
246
|
+
assert.equal(fs.existsSync(importPath), false, 'private teach import must be removed');
|
|
247
|
+
} finally { cleanup(root); }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const broken = tempProject('p-rep-teach-no-write-');
|
|
251
|
+
let calls = 0;
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(path.join(broken, '.claude'), 'not a directory\n');
|
|
254
|
+
assert.throws(() => writeInsight(broken, payload(), {
|
|
255
|
+
runner() { calls += 1; return { status: 0, stdout: '', stderr: '' }; },
|
|
256
|
+
}));
|
|
257
|
+
assert.equal(calls, 0, 'a failed Markdown write must make zero dz calls');
|
|
258
|
+
} finally { cleanup(broken); }
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('P16b - duplicate replay imports one learned row', () => {
|
|
262
|
+
const { writeInsight, stableTeachText, normalizePayload } = requireWriter();
|
|
263
|
+
const root = tempProject('p-rep-real-dz-teach-');
|
|
264
|
+
try {
|
|
265
|
+
const record = payload({ title: 'Replay-safe learned projection' });
|
|
266
|
+
const first = writeInsight(root, record);
|
|
267
|
+
const replay = writeInsight(root, { ...record, date: '2026-08-31' });
|
|
268
|
+
assert.equal(first.status, 'created');
|
|
269
|
+
assert.equal(first.teach.state, 'ok');
|
|
270
|
+
assert.equal(replay.status, 'duplicate');
|
|
271
|
+
assert.equal(replay.teach.state, 'ok');
|
|
272
|
+
|
|
273
|
+
const queried = spawnSync('dz', [
|
|
274
|
+
'recall', '--all', '--json', '--project', root,
|
|
275
|
+
], { cwd: root, encoding: 'utf8', timeout: 5000, maxBuffer: 1024 * 1024 });
|
|
276
|
+
assert.equal(queried.status, 0, queried.stderr || queried.stdout);
|
|
277
|
+
const rows = JSON.parse(queried.stdout);
|
|
278
|
+
const expected = stableTeachText(normalizePayload(record));
|
|
279
|
+
const matches = rows.filter((row) =>
|
|
280
|
+
row.domain === 'p-replicator-insights' && row.pattern === expected);
|
|
281
|
+
assert.equal(matches.length, 1,
|
|
282
|
+
`two exact replays must leave one learned row, got ${matches.length}`);
|
|
283
|
+
} finally { cleanup(root); }
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 0.5 (Source Product Profile) is a SECTION IN MARKDOWN that a model reads and executes —
|
|
4
|
+
// there is no deterministic phase engine in this package. So the deterministic layer available for
|
|
5
|
+
// the phase itself is the CONTENT of the three files that declare the sequence, and its ORDER.
|
|
6
|
+
//
|
|
7
|
+
// Why order is an assertion and not decoration: the phase's whole justification is WHERE it sits.
|
|
8
|
+
// Phase 0 is skipped entirely by the `--from-docs` entry, so a look-capture placed inside Phase 0
|
|
9
|
+
// would switch itself off for exactly the projects that arrive with someone else's documentation —
|
|
10
|
+
// which are, more often than not, replications of someone else's product. Move the section back
|
|
11
|
+
// inside Phase 0 and this file goes red; that is the point.
|
|
12
|
+
//
|
|
13
|
+
// The sequence is declared in THREE places and they must agree. Every assertion below is
|
|
14
|
+
// DISCRIMINATING: remove what it names and it goes red.
|
|
15
|
+
|
|
16
|
+
const { test, describe } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
22
|
+
const read = (...p) => fs.readFileSync(path.join(TPL, ...p), 'utf-8');
|
|
23
|
+
|
|
24
|
+
const CMD = read('commands', 'replicate.md');
|
|
25
|
+
const RULE = read('rules', 'replicate-pipeline.md');
|
|
26
|
+
const AGENT = read('agents', 'replicate-coordinator.md');
|
|
27
|
+
const CJM = read('skills', 'reverse-engineering-unicorn', 'modules', '025-cjm-prototype.md');
|
|
28
|
+
|
|
29
|
+
const PROFILE_PATH = 'docs/source-product-profile.md';
|
|
30
|
+
const CHECKER = 'check-look-trace.cjs';
|
|
31
|
+
const CAPTURE = 'capture-source-path.cjs';
|
|
32
|
+
/** The reasons a NAMED source may go uncaptured. Closed, because each names a DIFFERENT fix.
|
|
33
|
+
* The last four arrived with the browser instrument for the `путь` axis. */
|
|
34
|
+
const REASONS = ['no-browser-mcp', 'unreachable', 'auth-required', 'out-of-scope',
|
|
35
|
+
'no-browser', 'bot-protected', 'timeout', 'robots-disallowed'];
|
|
36
|
+
|
|
37
|
+
describe('Phase 0.5 is pinned where it must live, in all three places that declare the sequence', () => {
|
|
38
|
+
test('P1 - ORDER: the Phase 0.5 section sits between the Phase 0 checkpoint and Phase 1', () => {
|
|
39
|
+
// The first of three independent ways this file goes red when the phase is switched off.
|
|
40
|
+
const checkpoint0 = CMD.indexOf('✅ PHASE 0: PRODUCT DISCOVERY');
|
|
41
|
+
const phase05 = CMD.indexOf('### Phase 0.5: SOURCE PRODUCT PROFILE');
|
|
42
|
+
const phase1 = CMD.indexOf('### Phase 1: PLANNING');
|
|
43
|
+
|
|
44
|
+
assert.ok(checkpoint0 > 0, 'replicate.md must still carry the Phase 0 checkpoint');
|
|
45
|
+
assert.ok(phase1 > 0, 'replicate.md must still carry Phase 1');
|
|
46
|
+
assert.ok(phase05 > 0, 'replicate.md must carry a Phase 0.5 section heading');
|
|
47
|
+
assert.ok(phase05 > checkpoint0,
|
|
48
|
+
'Phase 0.5 must come AFTER the Phase 0 checkpoint — placed before it, it would be part of '
|
|
49
|
+
+ 'the optional phase and would vanish with it');
|
|
50
|
+
assert.ok(phase05 < phase1,
|
|
51
|
+
'Phase 0.5 must come BEFORE Phase 1 — Phase 1 promotes its FR-LOOK rows, so a phase running '
|
|
52
|
+
+ 'after Phase 1 would have nothing to promote into');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('P2 - the phase is MANDATORY and explicitly survives --from-docs', () => {
|
|
56
|
+
// The second independent way. This is the whole reason the phase is separate; a Phase 0.5 that
|
|
57
|
+
// the alternative entry also skipped would be the defect wearing the fix's clothes.
|
|
58
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
59
|
+
assert.match(head, /обязательная|ВСЕГДА/,
|
|
60
|
+
'the section heading/lead must say the phase always runs');
|
|
61
|
+
assert.match(head, /--from-docs|--skip-discovery/,
|
|
62
|
+
'and it must name the entry that skips Phase 0, so the reader knows what it survives');
|
|
63
|
+
|
|
64
|
+
// And the alternative-entry section itself must say so, because a reader who lands there is
|
|
65
|
+
// reading the list of what runs and what does not.
|
|
66
|
+
const alt = CMD.slice(CMD.indexOf('### Modified pipeline flow when triggered'),
|
|
67
|
+
CMD.indexOf('### Three sub-paths'));
|
|
68
|
+
assert.ok(alt.length > 0, 'replicate.md must still carry the modified-flow list');
|
|
69
|
+
assert.match(alt, /\*\*Phase 0\.5\*\*/, 'the modified-flow list must name Phase 0.5');
|
|
70
|
+
assert.match(alt, /RUNS/,
|
|
71
|
+
'and must say it RUNS — a list that names the phase without saying so reads as a skip');
|
|
72
|
+
|
|
73
|
+
const altRule = RULE.slice(RULE.indexOf('### Behavior'), RULE.indexOf('### Three supported sub-paths'));
|
|
74
|
+
assert.ok(altRule.length > 0, 'the rule must still carry the alternative-entry behaviour list');
|
|
75
|
+
assert.match(altRule, /Phase 0\.5 STILL RUNS/i,
|
|
76
|
+
'the rule\'s behaviour list must say Phase 0.5 still runs on the --from-docs entry');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('P3 - THREE PLACES agree on the sequence: command, rule, coordinator agent', () => {
|
|
80
|
+
// The third independent way. These three declare the phase order; a change to one alone is a
|
|
81
|
+
// second-generation lie — the pipeline documented as doing something two of its own files
|
|
82
|
+
// never mention.
|
|
83
|
+
assert.match(CMD, /Phase 0\.5/, 'replicate.md must declare Phase 0.5');
|
|
84
|
+
assert.match(RULE, /Phase 0\.5/, 'replicate-pipeline.md must declare Phase 0.5');
|
|
85
|
+
assert.match(AGENT, /Phase 0\.5|0\.5/, 'replicate-coordinator.md must declare Phase 0.5');
|
|
86
|
+
|
|
87
|
+
// The rule's own sequence diagram, not merely a mention somewhere in the file.
|
|
88
|
+
const seq = RULE.slice(RULE.indexOf('## Phase Sequence'), RULE.indexOf('## Skill Loading Protocol'));
|
|
89
|
+
assert.match(seq, /Phase 0\.5/, 'the Phase Sequence block itself must contain Phase 0.5');
|
|
90
|
+
assert.match(seq, /always|ALWAYS|всегда/,
|
|
91
|
+
'and must mark it as the one that always runs, next to the optional Phase 0');
|
|
92
|
+
|
|
93
|
+
// The coordinator's own numbered sequence.
|
|
94
|
+
assert.match(AGENT, /0\s*→\s*0\.5\s*→\s*1\s*→\s*2\s*→\s*3\s*→\s*4/,
|
|
95
|
+
'the coordinator must carry the full ordered sequence including 0.5');
|
|
96
|
+
|
|
97
|
+
// All three must name the SAME artifact, or "the profile" means three different files.
|
|
98
|
+
for (const [name, text] of [['replicate.md', CMD], ['replicate-pipeline.md', RULE],
|
|
99
|
+
['replicate-coordinator.md', AGENT]]) {
|
|
100
|
+
assert.ok(text.includes(PROFILE_PATH), name + ' must name ' + PROFILE_PATH);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('P4 - THREE outcomes are declared, and the third is the one with a closed reason list', () => {
|
|
105
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
106
|
+
for (const outcome of ['СНЯТ', 'НЕ ИЗМЕРЕНО', 'ИСТОЧНИКА НЕТ']) {
|
|
107
|
+
assert.ok(head.includes(outcome), 'the phase must declare the outcome ' + outcome);
|
|
108
|
+
}
|
|
109
|
+
for (const reason of REASONS) {
|
|
110
|
+
assert.ok(head.includes(reason),
|
|
111
|
+
'the closed reason list must be spelled out in the phase: missing ' + reason);
|
|
112
|
+
}
|
|
113
|
+
// Two outcomes would be the defect: "captured" and "not captured" collapses "we looked and there
|
|
114
|
+
// is nothing to look at" into "we could not look".
|
|
115
|
+
assert.match(head, /ТРИ исхода|три исхода/,
|
|
116
|
+
'the phase must say there are THREE outcomes, not two');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('P5 - the phase carries its own deterministic gate with three exit codes', () => {
|
|
120
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
121
|
+
assert.ok(head.includes(CHECKER), 'the phase must name its checker: ' + CHECKER);
|
|
122
|
+
assert.match(head, /проверка НЕ выполнена|НЕ выполнена/,
|
|
123
|
+
'exit 2 must be described as "the check did not run", never as a pass');
|
|
124
|
+
assert.ok(fs.existsSync(path.join(TPL, 'hooks', CHECKER)),
|
|
125
|
+
'and the checker the text names must actually ship');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('P6 - Phase 1 is told to promote FR-LOOK rows, or the seed has no destination', () => {
|
|
129
|
+
const phase1 = CMD.slice(CMD.indexOf('### Phase 1: PLANNING'),
|
|
130
|
+
CMD.indexOf('### Прерванный прогон'));
|
|
131
|
+
assert.match(phase1, /FR-LOOK/, 'Phase 1 must name the FR-LOOK family it promotes');
|
|
132
|
+
assert.match(phase1, /Specification\.md/, 'and the file it promotes them into');
|
|
133
|
+
assert.match(phase1, /FR-GROWTH/,
|
|
134
|
+
'both seed families are promoted by the same step — naming only one invites a second dialect');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('P7 - the industry palette is a FALLBACK, not an equal branch', () => {
|
|
138
|
+
// The defect this feature removes: a pipeline whose job is to reproduce a product invented its
|
|
139
|
+
// palette from the industry. The table stays — it is the right answer when there is no source —
|
|
140
|
+
// but it must be reachable only through the "no source / not measured" states.
|
|
141
|
+
const table = CJM.indexOf('| Health/Fitness | Emerald/Teal');
|
|
142
|
+
assert.ok(table > 0, 'the industry table must still exist — it is the legitimate fallback');
|
|
143
|
+
|
|
144
|
+
const precedence = CJM.indexOf('Порядок приоритета');
|
|
145
|
+
assert.ok(precedence > 0, 'a precedence block must exist above the table');
|
|
146
|
+
assert.ok(precedence < table,
|
|
147
|
+
'the precedence block must come BEFORE the table: a rule stated after the table is a rule the '
|
|
148
|
+
+ 'reader meets after already using it');
|
|
149
|
+
|
|
150
|
+
const between = CJM.slice(precedence, table);
|
|
151
|
+
assert.match(between, /СНЯТ/, 'the captured state must be named');
|
|
152
|
+
assert.match(between, /НЕ ИЗМЕРЕН/, 'the not-measured state must be named');
|
|
153
|
+
assert.match(between, /ФОЛБЭК|фолбэк/, 'the table must be labelled a fallback');
|
|
154
|
+
|
|
155
|
+
// The table's own caption must carry the condition, because a reader who jumps to the table
|
|
156
|
+
// sees the caption and nothing above it.
|
|
157
|
+
const caption = CJM.slice(CJM.lastIndexOf('**Design адаптация', table), table);
|
|
158
|
+
assert.match(caption, /ФОЛБЭК/,
|
|
159
|
+
'the table caption itself must say it is the fallback, not only the paragraph above it');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('P8 - the capture skill is CALLED, never vendored into this package', () => {
|
|
163
|
+
// ADR-0001 + sources.json: clone-website has one canonical home, in its own package. Vendoring
|
|
164
|
+
// it here would add an eleventh skill to a contract pinned in four places.
|
|
165
|
+
const shipped = fs.readdirSync(path.join(TPL, 'skills'));
|
|
166
|
+
assert.ok(!shipped.includes('clone-website'),
|
|
167
|
+
'clone-website must NOT be vendored into templates/.claude/skills/: ' + shipped.join(', '));
|
|
168
|
+
assert.match(CMD, /skills-website-cloner/,
|
|
169
|
+
'the phase must name the canonical package it calls');
|
|
170
|
+
assert.match(RULE, /skills-website-cloner/,
|
|
171
|
+
'and the rule must too, so the reference cannot drift to a copy');
|
|
172
|
+
|
|
173
|
+
// Its absence must be a legitimate outcome, not a blocked pipeline.
|
|
174
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
175
|
+
assert.match(head, /no-browser-mcp/,
|
|
176
|
+
'an unmet prerequisite must map to a named reason, not to a stalled run');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('P10 - the `путь` axis has an INSTRUMENT, and both files name it', () => {
|
|
180
|
+
// The axis existed as a column from the first day of Phase 0.5; nothing could fill it. A column
|
|
181
|
+
// with no instrument is a promise the pipeline cannot keep, and it would quietly stay empty
|
|
182
|
+
// forever — which reads exactly like "this product has no path requirements".
|
|
183
|
+
assert.ok(fs.existsSync(path.join(TPL, 'hooks', CAPTURE)), 'the instrument must ship: ' + CAPTURE);
|
|
184
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
185
|
+
assert.ok(head.includes(CAPTURE), 'the phase must name the instrument of its second axis');
|
|
186
|
+
assert.ok(RULE.includes(CAPTURE), 'and the rule must too, or the two describe different phases');
|
|
187
|
+
|
|
188
|
+
// Both axes must answer for themselves. One shared status would have to lie about one of them.
|
|
189
|
+
assert.match(head, /Статус съёмки \(путь\)/,
|
|
190
|
+
'the phase must carry the second axis\'s own status line');
|
|
191
|
+
assert.match(RULE, /Статус съёмки \(путь\)/, 'and so must the rule');
|
|
192
|
+
|
|
193
|
+
// The instrument is a PRECONDITION, not a dependency: the package ships zero of those.
|
|
194
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
195
|
+
assert.equal(pkg.dependencies, undefined,
|
|
196
|
+
'Playwright is an EXTERNAL prerequisite; putting it in dependencies would end the '
|
|
197
|
+
+ 'zero-dependency contract for a utility that honestly works without it');
|
|
198
|
+
assert.match(head + RULE, /no-browser/,
|
|
199
|
+
'and its absence must map to a named reason, not to a stalled run');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('P11 - the legality boundary lives in the RULE, not only in a report', () => {
|
|
203
|
+
// A boundary stated only where the finished work is described is a boundary nobody reads BEFORE
|
|
204
|
+
// crossing it. This one governs what may be captured at all, so it sits in the always-loaded
|
|
205
|
+
// rule, next to the instrument that could cross it.
|
|
206
|
+
const sec = RULE.slice(RULE.indexOf('### Phase 0.5'), RULE.indexOf('## Skill Loading Protocol'));
|
|
207
|
+
assert.ok(sec.length > 0, 'the rule must still carry its Phase 0.5 section');
|
|
208
|
+
assert.match(sec, /REGULARITIES|закономерн/i,
|
|
209
|
+
'the rule must say that REGULARITIES are what gets captured');
|
|
210
|
+
assert.match(sec, /VALUES|значени/i, 'and that VALUES are what does not');
|
|
211
|
+
assert.match(sec, /copyright/i, 'third-party CSS/DOM as copyrighted code must be named');
|
|
212
|
+
assert.match(sec, /trademark/i, 'the logo+name+colours-together trademark case must be named');
|
|
213
|
+
assert.match(sec, /robots\.txt/, 'reading robots.txt before a crawl must be named');
|
|
214
|
+
assert.match(sec, /FORBIDDEN|ЗАПРЕЩ/,
|
|
215
|
+
'circumvention and authentication must be refused in words, not merely discouraged');
|
|
216
|
+
assert.match(sec, /sx-ds2y8i|class names/i,
|
|
217
|
+
'and the brittleness rule — never class names — must be stated where the instrument is');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('P9 - the hook inventory in the rule matches what ships (the count is not decorative)', () => {
|
|
221
|
+
const { COMPONENTS } = require(path.join(__dirname, '..', '..', 'src', 'utils.js'));
|
|
222
|
+
const n = Object.keys(COMPONENTS.hooks.items).length;
|
|
223
|
+
const m = RULE.match(/\*\*Hooks \((\d+) files/);
|
|
224
|
+
assert.ok(m, 'replicate-pipeline.md must state the hook file count');
|
|
225
|
+
assert.equal(Number(m[1]), n,
|
|
226
|
+
'the rule advertises a hook count the package does not ship — the stale "8 files" line was '
|
|
227
|
+
+ 'exactly this defect, one generation earlier');
|
|
228
|
+
assert.ok(RULE.includes(CHECKER),
|
|
229
|
+
'and the new checker must be listed among the deliberately-invoked utilities');
|
|
230
|
+
});
|
|
231
|
+
});
|