@dzhechkov/p-replicator 1.10.4 → 1.12.0
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 +119 -47
- package/CHANGELOG.md +85 -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/README.md +1 -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/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +154 -38
- package/bin/cli.js +0 -0
- package/package.json +12 -10
- package/sbom.json +226 -46
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/rule-components.json +11 -0
- package/src/utils.js +3 -8
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/commands/feature.md +57 -9
- package/templates/.claude/commands/go.md +11 -0
- package/templates/.claude/commands/harvest.md +41 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +10 -1
- package/templates/.claude/commands/start.md +8 -0
- package/templates/.claude/hooks/check-ports.cjs +409 -20
- 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/feature-lifecycle.md +14 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/replicate-pipeline.md +4 -2
- 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/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +86 -16
- 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 +24 -20
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +729 -2
- package/tests/unit/db-port-rule.test.js +36 -5
- 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/honest-failure-rules.test.js +492 -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/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 +3 -2
- package/LICENSE +0 -21
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Two shipped documents promised something the hook does not do — and, as written, CANNOT do.
|
|
4
|
-
//
|
|
5
|
-
// `myinsights.md` said insights are injected "when their tags match the current task". The hook
|
|
6
|
-
// runs on SessionStart, BEFORE the user has said anything, so there is no current task to match
|
|
7
|
-
// tags against. MEASURED: `session-insights.cjs:33` is `sections.slice(-3)` — the last three by
|
|
8
|
-
// file order, and no tag matching exists anywhere in the package.
|
|
9
|
-
//
|
|
10
|
-
// The hook's own printed heading, "Recent project insights", was already honest. Only the documents
|
|
11
|
-
// around it were not.
|
|
12
|
-
//
|
|
13
|
-
// This test exists because a promise removed from prose comes back. It pins the CODE and the DOC to
|
|
14
|
-
// each other: if selection ever becomes relevance-based, this test is where the doc must change too.
|
|
15
|
-
|
|
16
3
|
const { test, describe } = require('node:test');
|
|
17
4
|
const assert = require('node:assert/strict');
|
|
18
5
|
const fs = require('node:fs');
|
|
19
6
|
const path = require('node:path');
|
|
20
7
|
|
|
21
8
|
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
9
|
+
const PKG = path.join(__dirname, '..', '..');
|
|
22
10
|
const CMD = path.join(TPL, 'commands', 'myinsights.md');
|
|
23
11
|
const HOOK = path.join(TPL, 'hooks', 'session-insights.cjs');
|
|
12
|
+
const RULE = path.join(TPL, 'rules', 'insights-capture.md');
|
|
13
|
+
const HARVEST = path.join(TPL, 'commands', 'harvest.md');
|
|
14
|
+
const README = path.join(PKG, 'README.md');
|
|
15
|
+
const API_EN = path.join(PKG, 'README', 'eng', '04_api_reference.md');
|
|
16
|
+
const API_RU = path.join(PKG, 'README', 'ru', '04_api_reference.md');
|
|
24
17
|
|
|
25
18
|
const read = (f) => fs.readFileSync(f, 'utf-8');
|
|
26
19
|
/** Prose with code fences and comments removed — a MENTION of a claim is not the claim. */
|
|
@@ -40,12 +33,13 @@ describe('the insights documents describe the hook that exists', () => {
|
|
|
40
33
|
|
|
41
34
|
test('P2 - the document states what the hook actually selects', () => {
|
|
42
35
|
const doc = read(CMD);
|
|
43
|
-
assert.match(doc, /
|
|
44
|
-
assert.match(doc, /
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
36
|
+
assert.match(doc, /UserPromptSubmit/, 'selection must occur after the real prompt exists');
|
|
37
|
+
assert.match(doc, /successful,? non-empty.*dz recall/is,
|
|
38
|
+
'the value-armed recall condition must be named');
|
|
39
|
+
assert.match(doc, /falls back.*three most recent/is,
|
|
40
|
+
'the local last-three fallback must remain visible');
|
|
41
|
+
assert.match(doc, /only.*suppresses.*local/is,
|
|
42
|
+
'binary presence alone must not read as readiness');
|
|
49
43
|
});
|
|
50
44
|
|
|
51
45
|
test('P3 - the code and the doc agree, asserted against the CODE', () => {
|
|
@@ -60,25 +54,52 @@ describe('the insights documents describe the hook that exists', () => {
|
|
|
60
54
|
});
|
|
61
55
|
|
|
62
56
|
test('P4 - the consequence of last-three is stated, not left to be discovered', () => {
|
|
63
|
-
// The file is append-only and the hook takes the last three, so a long-lived project stops
|
|
64
|
-
// seeing its earlier entries. insights-capture.md plans for 50+.
|
|
65
57
|
const doc = read(CMD);
|
|
66
58
|
assert.match(doc, /append-only/, 'the growth behaviour must be named');
|
|
67
|
-
assert.match(doc, /earlier ones stop being injected
|
|
59
|
+
assert.match(doc, /fallback.*earlier ones stop being injected/is,
|
|
68
60
|
'and its consequence, in plain words');
|
|
69
|
-
const rule = read(
|
|
61
|
+
const rule = read(RULE);
|
|
70
62
|
assert.match(rule, /50 entries|> 50/,
|
|
71
63
|
'the rule really does plan for a size the hook cannot show — that is the point');
|
|
72
64
|
});
|
|
73
65
|
|
|
74
|
-
test('P5 -
|
|
75
|
-
// MEASURED: `grep -ci insight` over harvest.md returns 0. The command promised harvest
|
|
76
|
-
// "extracts reusable patterns from insights", which nothing does.
|
|
66
|
+
test('P5 - /harvest and /myinsights name their executable shared-carrier contract', () => {
|
|
77
67
|
const doc = read(CMD);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
assert.
|
|
82
|
-
'
|
|
68
|
+
const harvest = read(HARVEST);
|
|
69
|
+
assert.doesNotMatch(doc, /does\s*\n?\s*NOT read `\.claude\/insights\/index\.md` today/,
|
|
70
|
+
'the old unwired admission became false once harvest acquired a writer');
|
|
71
|
+
assert.match(doc, /share the same Markdown carrier/i,
|
|
72
|
+
'manual and harvest capture must be described as one carrier, not two stores');
|
|
73
|
+
assert.match(harvest, /REQUIRED INSIGHT PERSISTENCE GATE/,
|
|
74
|
+
'the relationship must be executable wiring, not a prose aspiration');
|
|
75
|
+
assert.match(harvest, /\.claude\/hooks\/write-insight\.cjs/,
|
|
76
|
+
'the shipped writer boundary must be named exactly');
|
|
77
|
+
assert.match(harvest, /\.claude\/insights\/index\.md/,
|
|
78
|
+
'the command and /myinsights must converge on the same Markdown source of truth');
|
|
79
|
+
assert.match(harvest, /Only `created`, `appended`, or `duplicate`/,
|
|
80
|
+
'normal completion must be bound to the three canonical writer receipts');
|
|
81
|
+
assert.match(harvest, /writer exits non-zero.*MUST NOT\s+report.*completed/is,
|
|
82
|
+
'an unestablished write must remain a blocking harvest outcome');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('P6 - docs describe prompt-time armed recall and Markdown authority', () => {
|
|
86
|
+
const hook = read(HOOK);
|
|
87
|
+
assert.match(hook, /recall\.kind === 'ok'/,
|
|
88
|
+
'the docs cannot claim armed recall without the production predicate');
|
|
89
|
+
assert.doesNotMatch(hook, /command\s+-v\s+dz/,
|
|
90
|
+
'binary discovery must not become the suppression condition');
|
|
91
|
+
|
|
92
|
+
const docs = [read(CMD), read(RULE), read(HARVEST), read(README), read(API_EN), read(API_RU)];
|
|
93
|
+
for (const [index, doc] of docs.entries()) {
|
|
94
|
+
assert.match(doc, /Markdown/i, `behavior document ${index + 1} omits Markdown authority`);
|
|
95
|
+
assert.match(doc, /best-effort/i, `behavior document ${index + 1} omits optional teach`);
|
|
96
|
+
}
|
|
97
|
+
for (const doc of [docs[0], docs[1], docs[3], docs[4], docs[5]]) {
|
|
98
|
+
assert.match(doc, /UserPromptSubmit/, 'prompt-time delivery must be named');
|
|
99
|
+
assert.match(doc, /(?:non-empty|непуст(?:ой|ого)).*(?:recall|вывод)/is,
|
|
100
|
+
'successful non-empty output must be the documented arming condition');
|
|
101
|
+
assert.match(doc, /(?:local|локальн).*(?:fallback|фолб[эе]к)/is,
|
|
102
|
+
'absent/failing/empty recall must retain local delivery');
|
|
103
|
+
}
|
|
83
104
|
});
|
|
84
105
|
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, describe } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
|
|
9
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
10
|
+
const HOOK = path.join(PKG, 'templates', '.claude', 'hooks', 'session-insights.cjs');
|
|
11
|
+
const LOCAL = 'LOCAL_ONLY';
|
|
12
|
+
const DZ = 'DZ_ONLY';
|
|
13
|
+
|
|
14
|
+
function loadHook() {
|
|
15
|
+
delete require.cache[require.resolve(HOOK)];
|
|
16
|
+
return require(HOOK);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function project(prefix = 'p-rep-dz-contract-') {
|
|
20
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
21
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
22
|
+
fs.mkdirSync(path.dirname(index), { recursive: true });
|
|
23
|
+
fs.writeFileSync(index, `## 2026-08-30 — ${LOCAL}\n\nlocal body\n`);
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function userPrompt(prompt = 'choose an optional dependency fallback') {
|
|
28
|
+
return JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runHook(root, result, rawEvent = userPrompt()) {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
const runner = (_file, _args, _options) => result;
|
|
34
|
+
loadHook().emitInsights(root, { write: (chunk) => chunks.push(chunk) }, { rawEvent, runner });
|
|
35
|
+
return chunks.join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function envelope(stdout) {
|
|
39
|
+
const lines = stdout.trimEnd().split('\n');
|
|
40
|
+
assert.equal(lines.length, 1, `expected one hook envelope, got ${lines.length}: ${stdout}`);
|
|
41
|
+
const parsed = JSON.parse(lines[0]);
|
|
42
|
+
assert.equal(parsed.hookSpecificOutput.hookEventName, 'UserPromptSubmit');
|
|
43
|
+
return parsed.hookSpecificOutput.additionalContext;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recalled(pattern = DZ) {
|
|
47
|
+
return {
|
|
48
|
+
status: 0,
|
|
49
|
+
stdout: JSON.stringify([{ pattern, domain: 'p-replicator-insights' }]),
|
|
50
|
+
stderr: '',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function absent() {
|
|
55
|
+
return { status: null, stdout: '', stderr: '', error: { code: 'ENOENT' } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('PR-022 optional dz delivery contract', () => {
|
|
59
|
+
test('P10 - absent dz emits local only without dz error text', () => {
|
|
60
|
+
const root = project();
|
|
61
|
+
try {
|
|
62
|
+
const context = envelope(runHook(root, absent()));
|
|
63
|
+
assert.match(context, new RegExp(LOCAL));
|
|
64
|
+
assert.doesNotMatch(context, new RegExp(DZ));
|
|
65
|
+
assert.doesNotMatch(context, /dz recall (unavailable|failed)/i);
|
|
66
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('P11 - armed non-empty recall emits dz only', () => {
|
|
70
|
+
const root = project();
|
|
71
|
+
try {
|
|
72
|
+
const context = envelope(runHook(root, recalled()));
|
|
73
|
+
assert.match(context, new RegExp(DZ));
|
|
74
|
+
assert.doesNotMatch(context, new RegExp(LOCAL));
|
|
75
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('P12 - failing or timed-out recall names degradation and emits local only', () => {
|
|
79
|
+
const root = project();
|
|
80
|
+
const failures = [
|
|
81
|
+
{ status: 23, stdout: '', stderr: 'SECRET child stderr' },
|
|
82
|
+
{ status: null, stdout: '', stderr: '', error: { code: 'ETIMEDOUT' }, signal: 'SIGTERM' },
|
|
83
|
+
];
|
|
84
|
+
try {
|
|
85
|
+
for (const result of failures) {
|
|
86
|
+
const context = envelope(runHook(root, result));
|
|
87
|
+
assert.match(context, /dz recall unavailable: (exit 23|timeout); using local recent insights/i);
|
|
88
|
+
assert.match(context, new RegExp(LOCAL));
|
|
89
|
+
assert.doesNotMatch(context, new RegExp(DZ));
|
|
90
|
+
assert.doesNotMatch(context, /SECRET child stderr/);
|
|
91
|
+
}
|
|
92
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('P13 - empty successful recall emits local only', () => {
|
|
96
|
+
const root = project();
|
|
97
|
+
const emptyResults = [
|
|
98
|
+
{ status: 0, stdout: '[]', stderr: '' },
|
|
99
|
+
{ status: 0, stdout: JSON.stringify([
|
|
100
|
+
{ pattern: DZ, domain: 'another-domain' },
|
|
101
|
+
{ pattern: ' ', domain: 'p-replicator-insights' },
|
|
102
|
+
]), stderr: '' },
|
|
103
|
+
];
|
|
104
|
+
try {
|
|
105
|
+
for (const result of emptyResults) {
|
|
106
|
+
const context = envelope(runHook(root, result));
|
|
107
|
+
assert.match(context, new RegExp(LOCAL));
|
|
108
|
+
assert.doesNotMatch(context, new RegExp(DZ));
|
|
109
|
+
assert.doesNotMatch(context, /dz recall unavailable/i);
|
|
110
|
+
}
|
|
111
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('P14 - local suppression requires armed recall and never double-injects across absent/ok/failing/empty', () => {
|
|
115
|
+
const root = project();
|
|
116
|
+
const states = {
|
|
117
|
+
absent: absent(),
|
|
118
|
+
ok: recalled(),
|
|
119
|
+
failing: { status: 2, stdout: '', stderr: '' },
|
|
120
|
+
empty: { status: 0, stdout: '[]', stderr: '' },
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
for (const [state, result] of Object.entries(states)) {
|
|
124
|
+
const context = envelope(runHook(root, result));
|
|
125
|
+
const hasLocal = context.includes(LOCAL);
|
|
126
|
+
const hasDz = context.includes(DZ);
|
|
127
|
+
assert.notEqual(hasLocal, hasDz, `${state} must select exactly one insight source`);
|
|
128
|
+
assert.equal(hasDz, state === 'ok', `${state} armedness selected the wrong source`);
|
|
129
|
+
}
|
|
130
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('P15 - malformed successful stdout cannot arm recall', () => {
|
|
134
|
+
const root = project();
|
|
135
|
+
try {
|
|
136
|
+
for (const stdout of ['not-json', '{}']) {
|
|
137
|
+
const context = envelope(runHook(root, { status: 0, stdout, stderr: '' }));
|
|
138
|
+
assert.match(context, /dz recall unavailable: invalid (JSON|result)/i);
|
|
139
|
+
assert.match(context, new RegExp(LOCAL));
|
|
140
|
+
assert.doesNotMatch(context, new RegExp(DZ));
|
|
141
|
+
}
|
|
142
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('P15a - prompt parsing root pin argv and output bounds survive hostile input', () => {
|
|
146
|
+
const api = loadHook();
|
|
147
|
+
for (const key of ['prompt', 'user_prompt', 'userPrompt']) {
|
|
148
|
+
assert.deepEqual(
|
|
149
|
+
api.parseHookEvent(JSON.stringify({ hook_event_name: 'UserPromptSubmit', [key]: ' task ' })),
|
|
150
|
+
{ kind: 'user-prompt', prompt: 'task' },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const root = project('p rep dz contract ');
|
|
155
|
+
const calls = [];
|
|
156
|
+
try {
|
|
157
|
+
const result = api.recallFromDz('literal $(touch never) `echo never`', root, {
|
|
158
|
+
runner(file, args, options) {
|
|
159
|
+
calls.push({ file, args, options });
|
|
160
|
+
return recalled('X'.repeat(20 * 1024));
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
assert.equal(result.kind, 'ok');
|
|
164
|
+
assert.ok(Buffer.byteLength(result.context, 'utf8') <= 16 * 1024,
|
|
165
|
+
`rendered context exceeded 16 KiB: ${Buffer.byteLength(result.context, 'utf8')}`);
|
|
166
|
+
assert.equal(calls.length, 1);
|
|
167
|
+
assert.equal(calls[0].file, 'dz');
|
|
168
|
+
// The ADR's Decision pins this argv exactly, --domain included: it is a rank BOOST, and
|
|
169
|
+
// without it a shared multi-domain store buries insight records below the top-12 cut, so
|
|
170
|
+
// the armed state never fires. This assertion previously omitted the flag and therefore
|
|
171
|
+
// cemented the very gap cross-family QE found.
|
|
172
|
+
assert.deepEqual(calls[0].args, [
|
|
173
|
+
'recall', 'literal $(touch never) `echo never`', '--limit', '12',
|
|
174
|
+
'--domain', 'p-replicator-insights',
|
|
175
|
+
'--project', root, '--json',
|
|
176
|
+
]);
|
|
177
|
+
assert.equal(calls[0].options.cwd, root);
|
|
178
|
+
assert.equal(calls[0].options.shell, false);
|
|
179
|
+
assert.equal(calls[0].options.timeout, 1500);
|
|
180
|
+
assert.equal(calls[0].options.maxBuffer, 1024 * 1024);
|
|
181
|
+
|
|
182
|
+
const oversized = api.recallFromDz('task', root, {
|
|
183
|
+
runner: () => ({ status: null, stdout: '', stderr: '', error: { code: 'ENOBUFS' } }),
|
|
184
|
+
});
|
|
185
|
+
assert.deepEqual(oversized, { kind: 'failing', reason: 'spawn ENOBUFS' });
|
|
186
|
+
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('P19 - production comments contain no decision-history narration', () => {
|
|
190
|
+
for (const file of ['session-insights.cjs', 'write-insight.cjs']) {
|
|
191
|
+
const source = fs.readFileSync(path.join(PKG, 'templates', '.claude', 'hooks', file), 'utf8');
|
|
192
|
+
const comments = source.split('\n').filter((line) => /^\s*(\/\/|\/\*|\*)/.test(line)).join('\n');
|
|
193
|
+
assert.doesNotMatch(comments, /supersed|a0ea8b|cross-model|FIX-\d|AM-\d|third answer/i,
|
|
194
|
+
`${file} contains decision history that belongs in the ADR`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -115,12 +115,14 @@ const count = (out, key) => {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test('P4 - files[]
|
|
118
|
+
test('P4 - files[] ships only the deliberate checker, not the scripts directory', () => {
|
|
119
119
|
// The rejected alternative. Adding scripts/ fixes ONE of the two files and hands users build
|
|
120
120
|
// machinery for no reason; module-copy-identity needs sibling PACKAGES, which no tarball has.
|
|
121
121
|
const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
|
|
122
122
|
assert.ok(!(pkg.files || []).includes('scripts/'),
|
|
123
123
|
'shipping scripts/ was rejected: it fixes one file of two and ships build machinery');
|
|
124
|
+
assert.ok((pkg.files || []).includes('scripts/check-pipeline-gaps.sh'),
|
|
125
|
+
'the consumer-facing checker must be named explicitly in files[]');
|
|
124
126
|
assert.ok((pkg.files || []).includes('tests/'),
|
|
125
127
|
'this whole file only matters because tests/ ships — if that changes, revisit');
|
|
126
128
|
});
|