@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.
Files changed (68) hide show
  1. package/.dz-manifest.json +119 -47
  2. package/CHANGELOG.md +85 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +3 -3
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/04_api_reference.md +11 -5
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +3 -3
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/04_api_reference.md +11 -5
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +154 -38
  16. package/bin/cli.js +0 -0
  17. package/package.json +12 -10
  18. package/sbom.json +226 -46
  19. package/scripts/check-pipeline-gaps.sh +413 -0
  20. package/src/commands/doctor.js +94 -4
  21. package/src/rule-components.json +11 -0
  22. package/src/utils.js +3 -8
  23. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  24. package/templates/.claude/commands/feature.md +57 -9
  25. package/templates/.claude/commands/go.md +11 -0
  26. package/templates/.claude/commands/harvest.md +41 -3
  27. package/templates/.claude/commands/myinsights.md +21 -26
  28. package/templates/.claude/commands/replicate.md +10 -1
  29. package/templates/.claude/commands/start.md +8 -0
  30. package/templates/.claude/hooks/check-ports.cjs +409 -20
  31. package/templates/.claude/hooks/session-insights.cjs +158 -25
  32. package/templates/.claude/hooks/statusline.cjs +2 -2
  33. package/templates/.claude/hooks/write-insight.cjs +253 -0
  34. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  35. package/templates/.claude/rules/docker-ports.md +41 -19
  36. package/templates/.claude/rules/feature-lifecycle.md +14 -3
  37. package/templates/.claude/rules/honest-configuration.md +54 -0
  38. package/templates/.claude/rules/insights-capture.md +10 -5
  39. package/templates/.claude/rules/replicate-pipeline.md +4 -2
  40. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  41. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  42. package/templates/.claude/settings.json +13 -1
  43. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  44. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +86 -16
  45. package/tests/e2e/lifecycle.test.js +55 -9
  46. package/tests/e2e/packed-insights-writer.test.js +308 -0
  47. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  48. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  49. package/tests/snapshot/baseline.json +24 -20
  50. package/tests/snapshot/templates.test.js +47 -0
  51. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  52. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  53. package/tests/unit/check-ports.test.js +729 -2
  54. package/tests/unit/db-port-rule.test.js +36 -5
  55. package/tests/unit/detection-ladder-contract.test.js +302 -0
  56. package/tests/unit/detection-ladder-registry.test.js +52 -0
  57. package/tests/unit/doctor-insight-flow.test.js +315 -0
  58. package/tests/unit/external-dependency-check.test.js +19 -19
  59. package/tests/unit/honest-failure-rules.test.js +492 -0
  60. package/tests/unit/hooks-project-anchored.test.js +67 -3
  61. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  62. package/tests/unit/insights-dz-delegation.test.js +197 -0
  63. package/tests/unit/insights-writer.test.js +285 -0
  64. package/tests/unit/shipped-suite-context.test.js +3 -1
  65. package/tests/unit/traceability-machine-ids.test.js +413 -0
  66. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  67. package/tests/unit/utils.test.js +3 -2
  68. package/LICENSE +0 -21
@@ -1,40 +1,173 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- /**
5
- * SessionStart hook — injects up to 3 most recent insights from
6
- * .claude/insights/index.md into Claude's initial session context (via stdout).
7
- *
8
- * Cross-platform: pure Node, no shell pipes. Silent on missing index.
9
- */
10
-
4
+ const childProcess = require('node:child_process');
11
5
  const fs = require('node:fs');
12
6
  const path = require('node:path');
13
7
 
14
- // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
15
- // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
16
- // first the host is authoritative about what the project is. `__dirname` second: a hook always
17
- // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
18
- // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
8
+ const DOMAIN = 'p-replicator-insights';
9
+ const MAX_RENDER_BYTES = 16 * 1024;
10
+ const PROCESS_OPTIONS = Object.freeze({
11
+ encoding: 'utf8',
12
+ timeout: 1500,
13
+ killSignal: 'SIGTERM',
14
+ maxBuffer: 1024 * 1024,
15
+ shell: false,
16
+ });
17
+ const MISSING_HINT = 'инсайтов пока нет; /myinsights создаст первую запись\n';
19
18
  const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
20
- // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
21
- // cwd, which is the very bug this anchor exists to remove.
22
19
  const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
23
20
  ? ENV_ROOT
24
21
  : path.resolve(__dirname, '..', '..');
25
22
 
26
- const INDEX = path.resolve(ROOT, '.claude', 'insights', 'index.md');
23
+ function parseHookEvent(raw = '') {
24
+ const text = String(raw || '').trim();
25
+ if (!text) return { kind: 'session-start' };
26
+ let parsed;
27
+ try { parsed = JSON.parse(text); } catch (_error) {
28
+ return { kind: 'user-prompt', prompt: '' };
29
+ }
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
31
+ return { kind: 'user-prompt', prompt: '' };
32
+ }
33
+ const eventName = parsed.hook_event_name || parsed.hookEventName;
34
+ if (eventName === 'SessionStart') return { kind: 'session-start' };
35
+ for (const key of ['prompt', 'user_prompt', 'userPrompt']) {
36
+ if (typeof parsed[key] === 'string' && parsed[key].trim()) {
37
+ return { kind: 'user-prompt', prompt: parsed[key].trim() };
38
+ }
39
+ }
40
+ return eventName === 'UserPromptSubmit'
41
+ ? { kind: 'user-prompt', prompt: '' }
42
+ : { kind: 'session-start' };
43
+ }
27
44
 
28
- try {
29
- if (!fs.existsSync(INDEX)) process.exit(0);
30
- const text = fs.readFileSync(INDEX, 'utf8');
31
- // Each insight starts with "## " heading (per insights-capture.md convention).
45
+ function readLocalCarrier(root) {
46
+ const index = path.resolve(root, '.claude', 'insights', 'index.md');
47
+ if (!fs.existsSync(index)) return { kind: 'missing', context: '' };
48
+ const text = fs.readFileSync(index, 'utf8');
32
49
  const sections = text.split(/^## /m).filter(Boolean);
33
- const recent = sections.slice(-3).map((s) => '## ' + s.trim()).join('\n\n');
34
- if (recent.length > 0) {
35
- process.stdout.write('## Recent project insights\n\n' + recent + '\n');
50
+ const recent = sections.slice(-3).map((section) => '## ' + section.trim()).join('\n\n');
51
+ if (!recent) return { kind: 'empty', context: '' };
52
+ return { kind: 'populated', context: '## Recent project insights\n\n' + recent };
53
+ }
54
+
55
+ function truncateUtf8(value, maxBytes) {
56
+ const bytes = Buffer.from(value, 'utf8');
57
+ if (bytes.length <= maxBytes) return value;
58
+ let end = maxBytes;
59
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
60
+ return bytes.subarray(0, end).toString('utf8');
61
+ }
62
+
63
+ function renderRecallContext(rows) {
64
+ const patterns = rows
65
+ .filter((row) => row && row.domain === DOMAIN && typeof row.pattern === 'string')
66
+ .map((row) => row.pattern.trim())
67
+ .filter(Boolean)
68
+ .slice(0, 3);
69
+ if (!patterns.length) return undefined;
70
+ const heading = `## Recalled project insights (dz; ${patterns.length} hits)\n\n`;
71
+ const body = truncateUtf8(patterns.join('\n\n'), MAX_RENDER_BYTES - Buffer.byteLength(heading));
72
+ return body ? { context: heading + body, hitCount: patterns.length } : undefined;
73
+ }
74
+
75
+ function processFailure(error) {
76
+ if (error && error.code === 'ENOENT') return { kind: 'absent' };
77
+ if (error && error.code === 'ETIMEDOUT') return { kind: 'failing', reason: 'timeout' };
78
+ const code = error && typeof error.code === 'string' && /^[A-Z0-9_-]{1,32}$/.test(error.code)
79
+ ? error.code
80
+ : 'unknown';
81
+ return { kind: 'failing', reason: `spawn ${code}` };
82
+ }
83
+
84
+ function recallFromDz(prompt, root, { runner = childProcess.spawnSync } = {}) {
85
+ let result;
86
+ try {
87
+ // --domain is a rank BOOST, not a filter: the caller still keeps only records whose domain
88
+ // matches exactly. Without it, a shared multi-domain store buries insight records below the
89
+ // top-12 cut and the armed state effectively never fires.
90
+ result = runner('dz', ['recall', prompt, '--limit', '12', '--domain', DOMAIN, '--project', root, '--json'], {
91
+ ...PROCESS_OPTIONS,
92
+ cwd: root,
93
+ });
94
+ } catch (error) {
95
+ return processFailure(error);
96
+ }
97
+ if (result.error) return processFailure(result.error);
98
+ if (result.status !== 0) {
99
+ const status = Number.isInteger(result.status) ? result.status : 'unknown';
100
+ return { kind: 'failing', reason: `exit ${status}` };
101
+ }
102
+ let rows;
103
+ try { rows = JSON.parse(result.stdout || ''); } catch (_error) {
104
+ return { kind: 'failing', reason: 'invalid JSON' };
36
105
  }
37
- } catch (_err) {
38
- // Hook is advisory — never block the session on errors here.
39
- process.exit(0);
106
+ if (!Array.isArray(rows)) return { kind: 'failing', reason: 'invalid result' };
107
+ const rendered = renderRecallContext(rows);
108
+ return rendered ? { kind: 'ok', ...rendered } : { kind: 'empty' };
40
109
  }
110
+
111
+ function renderPromptEnvelope(additionalContext) {
112
+ return JSON.stringify({
113
+ hookSpecificOutput: {
114
+ hookEventName: 'UserPromptSubmit',
115
+ additionalContext,
116
+ },
117
+ }) + '\n';
118
+ }
119
+
120
+ function emitContext(context, output) {
121
+ if (typeof context === 'string' && context.trim()) {
122
+ output.write(renderPromptEnvelope(context));
123
+ }
124
+ }
125
+
126
+ function selectInsightOutput(localContext, recall) {
127
+ if (recall.kind === 'failing') {
128
+ return {
129
+ source: 'local',
130
+ context: `dz recall unavailable: ${recall.reason}; using local recent insights\n\n${localContext}`,
131
+ };
132
+ }
133
+ return { source: 'local', context: localContext };
134
+ }
135
+
136
+ function emitInsights(root = ROOT, output = process.stdout, dependencies = {}) {
137
+ try {
138
+ const event = parseHookEvent(dependencies.rawEvent || '');
139
+ const local = readLocalCarrier(root);
140
+ if (event.kind === 'session-start') {
141
+ if (local.kind === 'missing') output.write(MISSING_HINT);
142
+ return;
143
+ }
144
+ if (local.kind !== 'populated') return;
145
+ if (!event.prompt) {
146
+ emitContext(local.context, output);
147
+ return;
148
+ }
149
+ const recall = recallFromDz(event.prompt, root, { runner: dependencies.runner });
150
+ if (recall.kind === 'ok') {
151
+ emitContext(recall.context, output);
152
+ return;
153
+ }
154
+ const selected = selectInsightOutput(local.context, recall);
155
+ emitContext(selected.context, output);
156
+ } catch (_error) {}
157
+ }
158
+
159
+ function main() {
160
+ let rawEvent = '';
161
+ try { rawEvent = fs.readFileSync(0, 'utf8'); } catch (_error) {}
162
+ emitInsights(ROOT, process.stdout, { rawEvent });
163
+ }
164
+
165
+ module.exports = {
166
+ emitInsights,
167
+ parseHookEvent,
168
+ recallFromDz,
169
+ renderPromptEnvelope,
170
+ selectInsightOutput,
171
+ };
172
+
173
+ if (require.main === module) main();
@@ -244,8 +244,8 @@ function parseExpectedToolkit() {
244
244
  skillsExpected: 10,
245
245
  commandsExpected: 11,
246
246
  agentsExpected: 4, // pre-shipped only (project agents are extra)
247
- rulesExpected: 6, // pre-shipped only (project rules are extra)
248
- hooksExpected: 9, // 4 v1.4.1 hooks + statusline + state-update + 3 deliberate checks
247
+ rulesExpected: 9, // pre-shipped only (project rules are extra)
248
+ hooksExpected: 10, // 4 event hooks + statusline + state-update + writer + 3 deliberate checks
249
249
  };
250
250
  }
251
251
 
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('node:crypto');
5
+ const childProcess = 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 CARRIER = path.join('.claude', 'insights', 'index.md');
11
+ const DATE_HEADING = /^##\s+\d{4}-\d{2}-\d{2}/gm;
12
+ const TEACH_OPTIONS = Object.freeze({
13
+ encoding: 'utf8',
14
+ timeout: 1500,
15
+ killSignal: 'SIGTERM',
16
+ maxBuffer: 1024 * 1024,
17
+ shell: false,
18
+ });
19
+
20
+ class InsightValidationError extends Error {}
21
+
22
+ function normalizeText(value, field, { singleLine = false } = {}) {
23
+ if (typeof value !== 'string') {
24
+ throw new InsightValidationError(`${field} must be a string`);
25
+ }
26
+ const normalized = value.replace(/\r\n?/g, '\n').trim();
27
+ if (!normalized) throw new InsightValidationError(`${field} must not be blank`);
28
+ if (singleLine && normalized.includes('\n')) {
29
+ throw new InsightValidationError(`${field} must be one line`);
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ function normalizeArray(value, field) {
35
+ if (!Array.isArray(value)) {
36
+ throw new InsightValidationError(`${field} must be an array`);
37
+ }
38
+ return value.map((member, index) => {
39
+ if (typeof member !== 'string') {
40
+ throw new InsightValidationError(`${field}[${index}] must be a string`);
41
+ }
42
+ const normalized = member.replace(/\r\n?/g, '\n').trim();
43
+ if (normalized.includes('\n')) {
44
+ throw new InsightValidationError(`${field}[${index}] must be one line`);
45
+ }
46
+ return normalized;
47
+ }).filter(Boolean);
48
+ }
49
+
50
+ function normalizeDate(value) {
51
+ const date = normalizeText(value, 'date', { singleLine: true });
52
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
53
+ throw new InsightValidationError('date must use YYYY-MM-DD');
54
+ }
55
+ const parsed = new Date(`${date}T00:00:00.000Z`);
56
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
57
+ throw new InsightValidationError('date must be a real calendar date');
58
+ }
59
+ return date;
60
+ }
61
+
62
+ function normalizePayload(payload) {
63
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
64
+ throw new InsightValidationError('payload must be one JSON object');
65
+ }
66
+ return {
67
+ date: normalizeDate(payload.date),
68
+ title: normalizeText(payload.title, 'title', { singleLine: true }),
69
+ tags: normalizeArray(payload.tags, 'tags'),
70
+ problem: normalizeText(payload.problem, 'problem'),
71
+ solution: normalizeText(payload.solution, 'solution'),
72
+ references: normalizeArray(payload.references, 'references'),
73
+ };
74
+ }
75
+
76
+ function semanticId(record) {
77
+ const semantic = {
78
+ title: record.title,
79
+ tags: record.tags,
80
+ problem: record.problem,
81
+ solution: record.solution,
82
+ references: record.references,
83
+ };
84
+ return crypto.createHash('sha256').update(JSON.stringify(semantic)).digest('hex');
85
+ }
86
+
87
+ function stableTeachText(record) {
88
+ const tags = record.tags.length ? record.tags.join(', ') : 'none';
89
+ const references = record.references.length ? record.references.join(', ') : 'none';
90
+ // Date stays out because cross-date semantic duplicates must share one projection identity.
91
+ return [
92
+ 'p-replicator insight',
93
+ `Title: ${record.title}`,
94
+ `Tags: ${tags}`,
95
+ 'Problem:',
96
+ record.problem,
97
+ 'Solution:',
98
+ record.solution,
99
+ `References: ${references}`,
100
+ ].join('\n');
101
+ }
102
+
103
+ function teachFailure(error) {
104
+ if (error && error.code === 'ENOENT') return { state: 'absent' };
105
+ if (error && error.code === 'ETIMEDOUT') return { state: 'failed', reason: 'timeout' };
106
+ const code = error && typeof error.code === 'string' && /^[A-Z0-9_-]{1,32}$/.test(error.code)
107
+ ? error.code
108
+ : 'unknown';
109
+ return { state: 'failed', reason: `spawn ${code}` };
110
+ }
111
+
112
+ function teachDuplicate(record, projectRoot, { runner = childProcess.spawnSync } = {}) {
113
+ let temporary;
114
+ try {
115
+ temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'p-replicator-insight-teach-'));
116
+ const input = path.join(temporary, 'insight.json');
117
+ const rows = [{
118
+ pattern: stableTeachText(record),
119
+ type: 'lesson-learned',
120
+ reward: 0.8,
121
+ domain: 'p-replicator-insights',
122
+ }];
123
+ fs.writeFileSync(input, JSON.stringify(rows) + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
124
+ let result;
125
+ try {
126
+ result = runner('dz', ['teach', '--from-json', input, '--project', projectRoot], {
127
+ ...TEACH_OPTIONS,
128
+ cwd: projectRoot,
129
+ });
130
+ } catch (error) {
131
+ return teachFailure(error);
132
+ }
133
+ if (result.error) return teachFailure(result.error);
134
+ if (result.status !== 0) {
135
+ const status = Number.isInteger(result.status) ? result.status : 'unknown';
136
+ return { state: 'failed', reason: `exit ${status}` };
137
+ }
138
+ return { state: 'ok' };
139
+ } catch (_error) {
140
+ return { state: 'failed', reason: 'prepare import' };
141
+ } finally {
142
+ if (temporary) {
143
+ try { fs.rmSync(temporary, { recursive: true, force: true }); } catch (_error) {}
144
+ }
145
+ }
146
+ }
147
+
148
+ function renderEntry(record, id) {
149
+ const tags = record.tags.length ? record.tags.join(', ') : 'none';
150
+ const references = record.references.length ? record.references.join(', ') : 'none';
151
+ return [
152
+ `## ${record.date} — ${record.title}`,
153
+ '',
154
+ `<!-- insight-id: sha256:${id} -->`,
155
+ `**Tags:** ${tags}`,
156
+ '',
157
+ '**Problem:**',
158
+ record.problem,
159
+ '',
160
+ '**Solution:**',
161
+ record.solution,
162
+ '',
163
+ `**References:** ${references}`,
164
+ '',
165
+ '---',
166
+ '',
167
+ ].join('\n');
168
+ }
169
+
170
+ function entryCount(content) {
171
+ return (content.match(DATE_HEADING) || []).length;
172
+ }
173
+
174
+ function appendBoundary(content) {
175
+ if (!content) return '';
176
+ if (content.endsWith('\n\n')) return '';
177
+ if (content.endsWith('\n')) return '\n';
178
+ return '\n\n';
179
+ }
180
+
181
+ function atomicReplace(index, content) {
182
+ const temporary = `${index}.tmp-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
183
+ try {
184
+ fs.writeFileSync(temporary, content, { encoding: 'utf8', flag: 'wx' });
185
+ fs.renameSync(temporary, index);
186
+ } catch (error) {
187
+ try { fs.rmSync(temporary, { force: true }); } catch (_cleanupError) {}
188
+ throw error;
189
+ }
190
+ }
191
+
192
+ function writeInsight(projectRoot, payload, options = {}) {
193
+ if (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)) {
194
+ throw new InsightValidationError('project root must be absolute');
195
+ }
196
+
197
+ // Validate the complete record before even creating the missing parent directory.
198
+ const record = normalizePayload(payload);
199
+ const id = semanticId(record);
200
+ const marker = `<!-- insight-id: sha256:${id} -->`;
201
+ const insightsDir = path.resolve(projectRoot, '.claude', 'insights');
202
+ const index = path.join(insightsDir, 'index.md');
203
+ const existed = fs.existsSync(index);
204
+ const current = existed ? fs.readFileSync(index, 'utf8') : '';
205
+
206
+ let receipt;
207
+ if (current.includes(marker)) {
208
+ receipt = { status: 'duplicate', path: CARRIER.split(path.sep).join('/'),
209
+ entryCount: entryCount(current), id: `sha256:${id}` };
210
+ } else {
211
+ const next = current + appendBoundary(current) + renderEntry(record, id);
212
+ fs.mkdirSync(insightsDir, { recursive: true });
213
+ atomicReplace(index, next);
214
+ receipt = { status: existed ? 'appended' : 'created', path: CARRIER.split(path.sep).join('/'),
215
+ entryCount: entryCount(next), id: `sha256:${id}` };
216
+ }
217
+ receipt.teach = teachDuplicate(record, projectRoot, options);
218
+ return receipt;
219
+ }
220
+
221
+ function projectRoot() {
222
+ const fromHost = process.env.CLAUDE_PROJECT_DIR;
223
+ return (fromHost && path.isAbsolute(fromHost))
224
+ ? fromHost
225
+ : path.resolve(__dirname, '..', '..');
226
+ }
227
+
228
+ function main() {
229
+ try {
230
+ const raw = fs.readFileSync(0, 'utf8');
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(raw);
234
+ } catch (_error) {
235
+ throw new InsightValidationError('stdin must contain one valid JSON object');
236
+ }
237
+ const result = writeInsight(projectRoot(), parsed);
238
+ if (result.teach.state === 'failed') {
239
+ process.stderr.write(
240
+ `[write-insight] dz teach unavailable: ${result.teach.reason}; Markdown retained\n`,
241
+ );
242
+ }
243
+ process.stdout.write(JSON.stringify(result) + '\n');
244
+ } catch (error) {
245
+ const kind = error instanceof InsightValidationError ? 'invalid input' : 'write failed';
246
+ process.stderr.write(`[write-insight] ${kind}: ${error.message}\n`);
247
+ process.exitCode = 1;
248
+ }
249
+ }
250
+
251
+ module.exports = { writeInsight, normalizePayload, stableTeachText, teachDuplicate };
252
+
253
+ if (require.main === module) main();
@@ -0,0 +1,96 @@
1
+ # Cost-of-Detection Ladder
2
+
3
+ Use this rule when you design a safeguard for an engineering or architectural property. The goal is
4
+ to detect a violation early, consistently, and close the loop with a named response.
5
+
6
+ Put every safeguard on the **strongest layer that can reliably express the property**. “Strongest”
7
+ means cheapest to run, most deterministic, and hardest to skip. Move down the ladder only when the
8
+ property cannot be observed faithfully on a stronger layer.
9
+
10
+ ## The ladder (strongest to weakest)
11
+
12
+ ### Layer 1 — Deterministic test, CI check, or static guard
13
+
14
+ Use a repeatable executable check for properties such as file presence, size, format, exact paths,
15
+ schema shape, forbidden strings, and locally testable behavior. This layer is fast, machine-readable,
16
+ and does not depend on a model noticing the problem.
17
+
18
+ ### Layer 2 — Always-loaded governance
19
+
20
+ Use a rule or role document that is loaded on every relevant run for structural guidance that needs
21
+ context but must remain continuously visible. State the invariant and the evidence expected from the
22
+ implementation; do not rely on prose alone when Layer 1 can express the same property.
23
+
24
+ ### Layer 3 — Pipeline gate
25
+
26
+ Use a named pipeline step that records a machine-readable verdict for cross-artifact, workflow, or
27
+ semantic properties. A semantic or adversarial check may use an independent model-backed gate here,
28
+ but its verdict must be stored and the failure action must be explicit.
29
+
30
+ ### Layer 4 — Skill or reviewer judgment
31
+
32
+ Use task-invoked specialist judgment for properties that genuinely require interpretation and cannot
33
+ be made into a reliable stronger-layer gate. Its output is evidence for a decision, not a substitute
34
+ for a deterministic check that could have run earlier.
35
+
36
+ ### Layer 5 — Agent memory or informal recall
37
+
38
+ Use memory, convention, or “vibes” only as a prompt to create a real safeguard. This is the weakest
39
+ layer because it is probabilistic, easy to omit, and silent when forgotten.
40
+
41
+ ## Choose the check kind from the signal
42
+
43
+ **Check kind and enforcement layer are separate axes.** First identify the observable signal and the
44
+ mechanism that can observe it. Then place that mechanism on the strongest reliable layer above.
45
+
46
+ | Nature of the observable signal | Suitable check kind |
47
+ |---|---|
48
+ | Static structure or format | Static check or deterministic test |
49
+ | Local behavior | Unit test |
50
+ | Component interaction | Integration or contract test |
51
+ | Behavior over time | Monitor with a defined threshold or invariant |
52
+ | Runtime quantity | Metric and threshold alert |
53
+ | Discrete transition | Event or audit check |
54
+ | Failure resilience | Controlled fault injection |
55
+ | Semantic or adversarial property | Independent model-backed review gate with a recorded verdict |
56
+
57
+ Use only the rows relevant to the property. A mechanism is unsuitable if it cannot observe the signal
58
+ directly—for example, a static grep cannot establish runtime resilience.
59
+
60
+ ## Close the loop with a reaction
61
+
62
+ Every safeguard must connect the reason for the property to an observable signal, a recurring trigger,
63
+ and a response. Record it with this shape:
64
+
65
+ | Cause / property | Observable signal | Check kind | Layer | Trigger / cadence | Reaction | Owner |
66
+ |---|---|---|---|---|---|---|
67
+ | Why the constraint exists | What changes when it is violated | How it is observed | Where it is enforced | When it runs | What happens on failure | Who acts |
68
+
69
+ **Reaction must name a concrete action.** Valid reactions include: block or return the change, repair
70
+ the practice or implementation, escalate to the named owner, or revisit the decision explicitly.
71
+ A blank cell, “note the warning,” or “the reviewer decides” does not close the loop.
72
+
73
+ ## Design procedure
74
+
75
+ 1. State the property and why it exists.
76
+ 2. Name the observable signal produced by a violation.
77
+ 3. Select a check kind that can observe that signal.
78
+ 4. Place the check on the strongest layer that can express it reliably.
79
+ 5. Define its trigger or cadence, concrete Reaction, and owner.
80
+ 6. Test that the safeguard fires on a deliberately bad input before trusting the happy path.
81
+
82
+ ## Anti-pattern: “the critic/reviewer will catch it”
83
+
84
+ Deterministic properties must not be delegated to probabilistic review. If a short test can check a
85
+ path, count, format, registry entry, or forbidden value, put that check on Layer 1. Reviewer judgment
86
+ may complement the check for semantics; it must not carry a deterministic invariant by itself.
87
+
88
+ ## Worked example
89
+
90
+ | Cause / property | Observable signal | Check kind | Layer | Trigger / cadence | Reaction | Owner |
91
+ |---|---|---|---|---|---|---|
92
+ | Required configuration must ship with the package | The packed file list lacks the required path | Deterministic artifact-membership test | 1 | Every package build | Block the build and restore package wiring | Package maintainer |
93
+
94
+ The same property written only as “remember to include the file” would sit on Layer 5 and could fail
95
+ silently. The artifact test observes the real distribution boundary and defines what happens when it
96
+ breaks.
@@ -18,10 +18,20 @@ MariaDB, MongoDB, Redis, Elasticsearch/OpenSearch, MinIO, RabbitMQ, Memcached, C
18
18
 
19
19
  Разрешено ровно два состояния: публикации нет, либо она привязана к петле (`127.0.0.1` или `::1`).
20
20
 
21
- **Почему это правило №0, а не пожелание.** Тестовый Postgres, поднятый с `-p 55432:5432` и паролем
22
- `postgres`, был взломан из интернета примерно за час: через `COPY … TO PROGRAM` на машину въехал
23
- червь-майнер. Порт был открыт «на время отладки». Цена ошибки здесь — компрометация машины, а не
24
- неудобство, поэтому правило безусловное.
21
+ **Почему это правило №0, а не пожелание.** Одна и та же внешняя публикация создаёт две разные угрозы
22
+ в зависимости от механики аутентификации хранилища:
23
+
24
+ - **Класс A — Postgres, MySQL, MariaDB, MongoDB.** Наблюдаемый внешний binding открывает сервер для
25
+ попыток входа: слабый или подобранный пароль вместе с возможностями сервера может привести к
26
+ компрометации. Измеренный случай — тестовый Postgres с паролем `postgres`, взломанный примерно за
27
+ час; через `COPY … TO PROGRAM` на машину въехал червь-майнер.
28
+ - **Класс B — Redis, Valkey, KeyDB, Memcached.** Наблюдаемый внешний binding опаснее не из-за
29
+ стойкости пароля: образ этого класса по умолчанию не требует пароля, поэтому публикация сразу даёт
30
+ доступ без аутентификации.
31
+
32
+ Для обоих классов действие одно: убрать `ports:` и обращаться к хранилищу по имени в compose-сети.
33
+ Порт был открыт «на время отладки», но цена ошибки здесь — компрометация машины, а не неудобство,
34
+ поэтому правило безусловное.
25
35
 
26
36
  ## Что писать вместо
27
37
 
@@ -77,29 +87,41 @@ rate-limiting, заголовки. Известный конкретный сл
77
87
  ## Чем это правило НЕ является
78
88
 
79
89
  **Проверка теперь есть — но она не запускается сама.** В пакете отгружается
80
- `.claude/hooks/check-ports.cjs`: он читает нормализованный конфиг (`docker compose config`) и
81
- проверяет ровно этот инвариант, включая `network_mode: host` и обход reverse-proxy.
90
+ `.claude/hooks/check-ports.cjs` с двумя разными, намеренно выбираемыми областями:
82
91
 
83
92
  ```bash
84
- node .claude/hooks/check-ports.cjs . # или путь к конкретному compose-файлу
93
+ node .claude/hooks/check-ports.cjs . # каталог: один compose-проект
94
+ node .claude/hooks/check-ports.cjs --machine # снимок running-контейнеров текущего Docker-контекста
85
95
  ```
86
96
 
87
- Коды возврата три, и третий здесь главный: `0` правило соблюдено, `1` нарушено, **`2` проверка НЕ
88
- ВЫПОЛНЕНА** (нет compose, нет docker, конфиг не читается). Проверка, которая на нечитаемом конфиге
97
+ Каталоговый режим читает нормализованный конфиг (`docker compose config`) одного проекта, включая
98
+ `network_mode: host` и обход reverse-proxy, но никогда не опрашивает running-контейнеры. Режим
99
+ `--machine` перечисляет распознанные запущенные хранилища текущего Docker-контекста, смотрит их живые
100
+ bindings/Compose-метки и только там проверяет runtime-аутентификацию Redis. Это снимок в момент ручного
101
+ запуска, не непрерывный мониторинг и не утверждение о stopped-контейнерах или другом Docker context.
102
+
103
+ Для Redis/Memcached каталог отдельно проверяет видимую auth-конфигурацию: доказанное отсутствие
104
+ пароля даёт `1`, а volume/custom image, способный скрывать конфиг, — `2`, не ложный `0`. Существующее
105
+ исключение применяется раньше этой проверки: cache, опубликованный **только на loopback**, остаётся
106
+ разрешённым в каталоге; его фактическое беспарольное состояние обнаруживает только `--machine`.
107
+
108
+ Коды возврата — три, и третий здесь главный: `0` выбранная область проверена и нарушение не найдено,
109
+ `1` найдено доказанное нарушение, **`2` проверка НЕ ВЫПОЛНЕНА** полностью (нет compose/docker/прав,
110
+ конфиг или обязательный runtime probe не дают ответа). Проверка, которая на неизвестном состоянии
89
111
  отвечает «чисто», хуже отсутствия проверки: она превращает неизвестность в заверение.
90
112
 
91
- **Чего она НЕ делает.** Она не привязана ни к какому событию её надо позвать. Она смотрит на ВАШ
92
- проект, а не на машину: конфликты с портами, уже занятыми другими контейнерами, она не ищет. И она
93
- проверяет конфиг, а не запущенный стек.
113
+ **Чего не доказывает каталоговая квитанция. ЭКСПОЗИЦИЯ:** соседний проект на этой же машине может
114
+ держать хранилище открытым в интернет, а каталоговая проверка вашего проекта всё равно ответит `0`.
115
+ Для ответа о распознанных running-хранилищах всей машины отдельно вызывайте `--machine`; и этот ответ
116
+ ограничен снимком текущего Docker-контекста в момент запуска.
94
117
 
95
- Детерминированная проверка существует отдельно (`check-port-conflicts.sh`, «Правило №0») и в этот
96
- пакет не входит. Причина: она на bash, а ХУКИ пакета принципиально на кросс-платформенном Node —
97
- ради Windows, и проверка портов по форме именно хук. (Точности ради: пара вспомогательных `.sh`
98
- в пакете уже есть, внутри навыка `brutal-honesty-review`; обещание Node относится к хукам, а не ко
99
- всему пакету.) Если проверка вам нужна, её надо взять и подключить осознанно.
118
+ Левая часть `${DB_PORT:-11432}:5432` задаёт значение `HOST_PORT`, но ничего не говорит об адресе
119
+ публикации. Адрес появляется только в трёхчастной форме
120
+ `HOST_IP:HOST_PORT:CONTAINER_PORT`. Если `HOST_IP` опущен, Docker по умолчанию публикует на
121
+ `0.0.0.0` то есть на все интерфейсы может также создать IPv6 binding на `::`).
100
122
 
101
- Не выводите из присутствия этого правила, что кто-то за вас смотрит. Пока проверки нет, единственный
102
- контроль глаза на ревью, и знать об этом важнее, чем иметь правило.
123
+ **Чего она НЕ делает.** Она не привязана ни к какому событию её надо позвать. Не выводите из
124
+ присутствия правила или последней зелёной квитанции, что кто-то продолжает смотреть за машиной.
103
125
 
104
126
  ## Быстрая самопроверка
105
127