@dzhechkov/p-replicator 1.5.18 → 1.9.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 (89) hide show
  1. package/.dz-manifest.json +173 -57
  2. package/CHANGELOG.md +262 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +139 -16
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +346 -56
  19. package/src/utils.js +4 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +183 -9
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-docs-complete.cjs +174 -0
  29. package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
  30. package/templates/.claude/hooks/check-ports.cjs +232 -0
  31. package/templates/.claude/hooks/session-insights.cjs +13 -1
  32. package/templates/.claude/hooks/state-update.cjs +13 -1
  33. package/templates/.claude/hooks/statusline.cjs +145 -18
  34. package/templates/.claude/rules/docker-ports.md +123 -0
  35. package/templates/.claude/rules/replicate-pipeline.md +19 -6
  36. package/templates/.claude/rules/skill-interface-protocol.md +9 -0
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +6 -4
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  44. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  45. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  46. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  47. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +2 -2
  48. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  49. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  50. package/templates/.claude/skills/requirements-validator/SKILL.md +59 -0
  51. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  52. package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
  53. package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
  54. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
  55. package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
  56. package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
  57. package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
  58. package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
  59. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +152 -10
  60. package/tests/e2e/lifecycle.test.js +21 -10
  61. package/tests/snapshot/baseline.json +42 -38
  62. package/tests/snapshot/update-baseline.js +2 -1
  63. package/tests/unit/adr-decision-coverage.test.js +137 -0
  64. package/tests/unit/adr-scanner-contract.test.js +108 -0
  65. package/tests/unit/autocommit-deletion.test.js +242 -0
  66. package/tests/unit/check-docs-complete.test.js +249 -0
  67. package/tests/unit/check-growth-trace.test.js +188 -0
  68. package/tests/unit/check-ports.test.js +184 -0
  69. package/tests/unit/db-port-rule.test.js +216 -0
  70. package/tests/unit/detect-parse-anchor.test.js +109 -0
  71. package/tests/unit/external-dependency-check.test.js +209 -0
  72. package/tests/unit/growth-axes-and-compliance.test.js +169 -0
  73. package/tests/unit/growth-gate-conditional.test.js +122 -0
  74. package/tests/unit/growth-module-b2b-gate.test.js +122 -0
  75. package/tests/unit/growth-requirements-bridge.test.js +127 -0
  76. package/tests/unit/hooks-project-anchored.test.js +223 -0
  77. package/tests/unit/hooks-report-failures.test.js +207 -0
  78. package/tests/unit/module-copy-identity.test.js +76 -0
  79. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  80. package/tests/unit/roadmap-one-schema.test.js +179 -0
  81. package/tests/unit/skill-paths-prebaked.test.js +174 -0
  82. package/tests/unit/sparc-reconciliation.test.js +117 -0
  83. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  84. package/tests/unit/statusline-honest-labels.test.js +178 -0
  85. package/tests/unit/statusline-two-roots.test.js +237 -0
  86. package/tests/unit/sync-templates-guard.test.js +209 -0
  87. package/tests/unit/utils.test.js +2 -2
  88. package/tests/unit/validation-gate-teeth.test.js +158 -0
  89. package/LICENSE +0 -21
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-ports.cjs — enforce `.claude/rules/docker-ports.md` («Правило №0») against a real compose.
6
+ *
7
+ * NOT an event hook. Like `state-update.cjs`, it lives here because this directory already carries
8
+ * plain Node utilities; nothing registers it in settings.json and nothing runs it on a schedule.
9
+ * Invoke it deliberately:
10
+ *
11
+ * node .claude/hooks/check-ports.cjs [path-to-project | path-to-compose-file]
12
+ *
13
+ * Exit codes — three, and the third is the point:
14
+ * 0 the rule holds
15
+ * 1 the rule is violated (each violation is printed with the service and the remedy)
16
+ * 2 THE CHECK DID NOT RUN — no compose, no docker, or a config that would not parse
17
+ *
18
+ * A guard that answers "clean" when it could not look is worse than no guard: it converts an unknown
19
+ * into a reassurance. That is why an absent compose, a missing docker and an unreadable config all
20
+ * exit 2 rather than 0.
21
+ *
22
+ * It implements the RULE, not any prior script. In particular it checks `network_mode: host`, which
23
+ * publishes everything a container listens on without any `ports:` entry at all.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const { spawnSync } = require('node:child_process');
29
+
30
+ // Storage recognised the way the rule names it — by image and by well-known port. Both lists live
31
+ // here, side by side, so a reader extending one can see the other.
32
+ // Recognition is a HEURISTIC and stays one — but it must not be a substring match. A substring
33
+ // called `rediscommander/redis-commander` storage (it is a web UI), and missed
34
+ // `mcr.microsoft.com/mssql/server` on its standard 1433 entirely. So: take the image NAME —
35
+ // the last path component, without registry, tag or digest — and anchor to it.
36
+ const STORAGE_NAMES = /^(postgres|postgresql|pgvector|mysql|mariadb|percona|mongo|mongodb|redis|valkey|keydb|elasticsearch|opensearch|minio|rabbitmq|memcached|clickhouse|cassandra|scylla|neo4j|influxdb|timescaledb|mssql|sqlserver|couchdb|etcd)$/i;
37
+ const STORAGE_PORT = new Set([5432, 3306, 27017, 6379, 9200, 9300, 5672, 11211, 9000, 8123,
38
+ 1433, 9042, 7687, 8086, 2379, 5984]);
39
+ const PROXY_NAMES = /^(caddy|nginx|traefik|haproxy|envoy)$/i;
40
+
41
+ /** `mcr.microsoft.com/mssql/server:2022` → `server`; `postgres:16` → `postgres`. */
42
+ function imageName(image) {
43
+ const noDigest = String(image || '').split('@')[0];
44
+ const last = noDigest.split('/').pop() || '';
45
+ return last.split(':')[0];
46
+ }
47
+ /** The whole path matters too: mssql/server names the engine one component up. */
48
+ function imageParts(image) {
49
+ const noDigest = String(image || '').split('@')[0].split(':')[0];
50
+ return noDigest.split('/').filter(Boolean);
51
+ }
52
+
53
+ function say(s) { process.stdout.write(s + '\n'); }
54
+
55
+ /** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
56
+ function cannotCheck(reason, hint) {
57
+ say('⚠️ проверка НЕ выполнена: ' + reason);
58
+ if (hint) say(' ' + hint);
59
+ process.exit(2);
60
+ }
61
+
62
+ function resolveCompose(arg) {
63
+ const target = arg || '.';
64
+ let file = target;
65
+ try {
66
+ if (fs.statSync(target).isDirectory()) file = path.join(target, 'docker-compose.yml');
67
+ } catch {
68
+ cannotCheck('путь не существует: ' + target);
69
+ }
70
+ if (!fs.existsSync(file)) cannotCheck('нет файла ' + file);
71
+ return file;
72
+ }
73
+
74
+ /** The normalised config. Parsing the raw YAML would re-implement `extends`, interpolation and the
75
+ * short `"5432:5432"` form — and the short form is exactly where a hand parser gets host_ip wrong. */
76
+ function normalisedConfig(file) {
77
+ const r = spawnSync('docker', ['compose', '-f', file, 'config'],
78
+ { encoding: 'utf8', cwd: path.dirname(path.resolve(file)) });
79
+ if (r.error && r.error.code === 'ENOENT') {
80
+ cannotCheck('docker недоступен на этой машине',
81
+ 'без него нормализованный конфиг получить нечем, а разбирать YAML руками — значит ошибиться на короткой форме портов');
82
+ }
83
+ if (r.status !== 0) {
84
+ const why = String(r.stderr || '').trim().split('\n')[0] || 'причина неизвестна';
85
+ cannotCheck('docker compose config вернул ошибку: ' + why,
86
+ 'обычно это незаданная переменная; посмотреть: docker compose -f ' + file + ' config');
87
+ }
88
+ return String(r.stdout || '');
89
+ }
90
+
91
+ /**
92
+ * Services with what this check needs.
93
+ *
94
+ * The parser must know WHICH FIELD it is inside. A first version started a port record at every YAML
95
+ * sequence item and matched `published:` anywhere on a line, so this file — a perfectly legal one —
96
+ * reported a violation that does not exist:
97
+ *
98
+ * services:
99
+ * db:
100
+ * image: postgres:16
101
+ * command:
102
+ * - postgres
103
+ * - -c
104
+ * - "log_line_prefix=published: 6543"
105
+ *
106
+ * A check that invents violations is worse than one that misses them: it teaches people to ignore it.
107
+ * So: indentation decides the service, the field under a service is tracked by name, and only inside
108
+ * `ports:` does a sequence item begin a port record. Every field pattern is anchored to the start of
109
+ * the line.
110
+ */
111
+ function parseServices(yaml) {
112
+ const services = [];
113
+ let cur = null;
114
+ let inServices = false;
115
+ let field = ''; // the service-level key we are inside right now
116
+ let fieldIndent = 0;
117
+ let port = null;
118
+
119
+ const flush = () => { if (port && cur) { cur.ports.push(port); } port = null; };
120
+
121
+ for (const raw of yaml.split('\n')) {
122
+ if (/^services:\s*$/.test(raw)) { inServices = true; continue; }
123
+ if (/^\S/.test(raw)) { flush(); inServices = /^services:/.test(raw); cur = null; field = ''; continue; }
124
+ if (!inServices || !raw.trim()) continue;
125
+
126
+ const indent = raw.length - raw.replace(/^\s+/, '').length;
127
+
128
+ const svc = raw.match(/^ {2}([A-Za-z0-9_.-]+):\s*$/);
129
+ if (svc) {
130
+ flush();
131
+ cur = { name: svc[1], image: '', networkMode: '', ports: [] };
132
+ services.push(cur);
133
+ field = ''; fieldIndent = 0;
134
+ continue;
135
+ }
136
+ if (!cur) continue;
137
+
138
+ // A service-level key: ` image:`, ` ports:`, ` command:` …
139
+ const key = raw.match(/^ {4}([A-Za-z0-9_]+):\s*(.*)$/);
140
+ if (key) {
141
+ flush();
142
+ field = key[1];
143
+ fieldIndent = indent;
144
+ if (field === 'image') cur.image = key[2].trim();
145
+ if (field === 'network_mode') cur.networkMode = key[2].trim().replace(/^"|"$/g, '');
146
+ continue;
147
+ }
148
+ if (indent <= fieldIndent && field) { /* still inside the same field's block */ }
149
+
150
+ // Only inside `ports:` does a sequence item mean anything to this check. Under `command:`,
151
+ // `environment:` or `labels:` a line may contain any text at all, including the word published.
152
+ if (field !== 'ports') continue;
153
+
154
+ if (/^\s+-\s/.test(raw)) { flush(); port = { hostIp: '', published: '', target: '' }; }
155
+ if (!port) continue;
156
+ const hip = raw.match(/^\s+host_ip:\s*"?([^"\s]+)"?\s*$/);
157
+ if (hip) { port.hostIp = hip[1]; continue; }
158
+ const pub = raw.match(/^\s+published:\s*"?([^"\s]+)"?\s*$/);
159
+ if (pub) { port.published = pub[1]; continue; }
160
+ const tgt = raw.match(/^\s+target:\s*"?([0-9]+)"?\s*$/);
161
+ if (tgt) { port.target = tgt[1]; continue; }
162
+ // The short form survives normalisation in some versions: `- "127.0.0.1:55432:5432"`.
163
+ const short = raw.match(/^\s+-\s+"?(?:(\[?[0-9a-fA-F.:]+\]?):)?([0-9]+):([0-9]+)"?\s*$/);
164
+ if (short) { port.hostIp = short[1] || ''; port.published = short[2]; port.target = short[3]; }
165
+ }
166
+ flush();
167
+ return services;
168
+ }
169
+
170
+ const isLoopback = (hostIp) => /^127\./.test(hostIp) || hostIp === '::1' || hostIp === '[::1]';
171
+ const isStorage = (svc) =>
172
+ imageParts(svc.image).some((part) => STORAGE_NAMES.test(part))
173
+ || svc.ports.some((p) => STORAGE_PORT.has(Number(p.target)));
174
+ const isProxy = (svc) => imageParts(svc.image).some((part) => PROXY_NAMES.test(part));
175
+
176
+ function main() {
177
+ const file = resolveCompose(process.argv[2]);
178
+ const services = parseServices(normalisedConfig(file));
179
+ if (!services.length) {
180
+ cannotCheck('в нормализованном конфиге не нашлось ни одного сервиса',
181
+ 'разбор мог не совпасть с форматом вывода вашей версии docker — это не «нарушений нет»');
182
+ }
183
+
184
+ const bad = [];
185
+
186
+ for (const svc of services) {
187
+ // network_mode: host publishes everything the container listens on, with no ports: entry at all.
188
+ // The rule forbids it for storage; the script this was rewritten from never checked it.
189
+ if (isStorage(svc) && svc.networkMode === 'host') {
190
+ bad.push(svc.name + ': network_mode: host — контейнер слушает прямо на хосте, публикации не '
191
+ + 'видно, а порт наружу. Хранилищу этот режим не подходит');
192
+ continue;
193
+ }
194
+ if (!isStorage(svc)) continue;
195
+ for (const p of svc.ports) {
196
+ if (!p.published) continue;
197
+ if (isLoopback(p.hostIp)) continue; // the loopback exception IS part of the rule
198
+ bad.push(svc.name + ': порт ' + p.published + ' → ' + (p.target || '?')
199
+ + (p.hostIp ? ' (host_ip ' + p.hostIp + ')' : ' (без адреса — значит все интерфейсы)')
200
+ + ' — хранилище опубликовано наружу. Убрать ports: целиком, либо привязать к 127.0.0.1');
201
+ }
202
+ }
203
+
204
+ // Behind a reverse-proxy the proxy is the only door; anything published beside it can be reached
205
+ // directly, bypassing everything the proxy guarantees.
206
+ const proxies = services.filter(isProxy);
207
+ if (proxies.length) {
208
+ for (const svc of services) {
209
+ if (isProxy(svc)) continue;
210
+ const pub = svc.ports.filter((p) => p.published && !isLoopback(p.hostIp));
211
+ if (pub.length) {
212
+ bad.push(svc.name + ': публикуется рядом с reverse-proxy ('
213
+ + proxies.map((p) => p.name).join(', ') + ') — прокси обходится прямым обращением');
214
+ }
215
+ }
216
+ }
217
+
218
+ if (bad.length) {
219
+ say('❌ Правило №0 нарушено (.claude/rules/docker-ports.md):');
220
+ for (const b of bad) say(' • ' + b);
221
+ process.exit(1);
222
+ }
223
+ say('✅ ни одно хранилище не публикует порт наружу, обходов reverse-proxy нет');
224
+ process.exit(0);
225
+ }
226
+
227
+ try {
228
+ main();
229
+ } catch (err) {
230
+ // Even an unexpected failure must not read as "clean".
231
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
232
+ }
@@ -11,7 +11,19 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
 
14
- const INDEX = path.resolve(process.cwd(), '.claude', 'insights', 'index.md');
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).
19
+ 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
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
23
+ ? ENV_ROOT
24
+ : path.resolve(__dirname, '..', '..');
25
+
26
+ const INDEX = path.resolve(ROOT, '.claude', 'insights', 'index.md');
15
27
 
16
28
  try {
17
29
  if (!fs.existsSync(INDEX)) process.exit(0);
@@ -26,7 +26,19 @@
26
26
  const fs = require('node:fs');
27
27
  const path = require('node:path');
28
28
 
29
- const STATE_FILE = path.resolve(process.cwd(), '.claude', '.p-replicator-state.json');
29
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
30
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
31
+ // first — the host is authoritative about what the project is. `__dirname` second: a hook always
32
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
33
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
34
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
35
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
36
+ // cwd, which is the very bug this anchor exists to remove.
37
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
38
+ ? ENV_ROOT
39
+ : path.resolve(__dirname, '..', '..');
40
+
41
+ const STATE_FILE = path.resolve(ROOT, '.claude', '.p-replicator-state.json');
30
42
 
31
43
  function parseArgs(argv) {
32
44
  const out = {};
@@ -17,7 +17,25 @@
17
17
  const fs = require('node:fs');
18
18
  const path = require('node:path');
19
19
 
20
- const CWD = process.cwd();
20
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
21
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
22
+ // first — the host is authoritative about what the project is. `__dirname` second: a hook always
23
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
24
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
25
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
26
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
27
+ // cwd, which is the very bug this anchor exists to remove.
28
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
29
+ ? ENV_ROOT
30
+ : path.resolve(__dirname, '..', '..');
31
+
32
+ const CWD = ROOT;
33
+
34
+ // Two questions, two names. WHERE THE INSTRUMENTS ARE is answered by the hook's own location and is
35
+ // correct from any cwd; WHICH PROJECT'S ROADMAP TO SHOW is a different question, answered by the
36
+ // survey below. Collapsing them into one anchor is what made the status line report half the truth
37
+ // from every directory: the toolkit half from the project directory, the roadmap half from the root.
38
+ const TOOLKIT_ROOT = ROOT;
21
39
  const NOW = Date.now();
22
40
 
23
41
  // ─── ANSI helpers ─────────────────────────────────────────────────────────
@@ -77,8 +95,8 @@ function parseState() {
77
95
  return state;
78
96
  }
79
97
 
80
- function parseRoadmap() {
81
- const r = safeReadJson(path.join(CWD, '.claude', 'feature-roadmap.json'));
98
+ function summariseRoadmap(file) {
99
+ const r = safeReadJson(file);
82
100
  if (!r || !Array.isArray(r.features)) return null;
83
101
  const features = r.features;
84
102
  const total = features.length;
@@ -87,7 +105,48 @@ function parseRoadmap() {
87
105
  const blocked = features.filter((f) => f.status === 'blocked').length;
88
106
  const mvp = features.filter((f) => f.priority === 'mvp');
89
107
  const mvpDone = mvp.filter((f) => f.status === 'done').length;
90
- return { total, done, inProgress, blocked, mvpTotal: mvp.length, mvpDone };
108
+ // A roadmap that does not match the schema used to render "mvp 0/0" and say nothing, so the
109
+ // divergence became permanent: the number was right, the reader learned nothing. `priority` is a
110
+ // CLOSED set (.claude/commands/next.md). Anything else — a value like "critical", or MVP moved
111
+ // into `tags` leaving no `priority` at all — is marked, never silently counted as zero.
112
+ const PRIORITIES = ['mvp', 'high', 'medium', 'low'];
113
+ // EVERY feature must carry a priority from the set. Counting only the ones that already have a
114
+ // string hid the mixed case: one valid entry beside a missing or non-string sibling passed
115
+ // silently, which is the most likely real roadmap of all.
116
+ const offSchemaCount = features.filter(
117
+ (f) => typeof f.priority !== 'string' || !PRIORITIES.includes(f.priority)).length;
118
+ return { total, done, inProgress, blocked, mvpTotal: mvp.length, mvpDone, offSchemaCount };
119
+ }
120
+
121
+ function parseRoadmap() {
122
+ return summariseRoadmap(path.join(CWD, '.claude', 'feature-roadmap.json'));
123
+ }
124
+
125
+ // A SURVEY, not a guess. The roadmap may live one level DOWN (a course with projects/01-app), which
126
+ // is why walking upward does not treat this symptom. Enumerating is honest where guessing is not,
127
+ // and a cwd-based guess is exactly the drift this whole line of work removed.
128
+ // This runs on EVERY prompt, so the survey is bounded on both axes: how many entries it will look
129
+ // at, and how large a file it will parse. Without those bounds a projects/ directory with hundreds
130
+ // of entries, or one enormous roadmap, would add its cost to every keystroke's status line.
131
+ const SUB_SCAN_LIMIT = 24; // entries examined per render
132
+ const SUB_ROADMAP_MAX_BYTES = 512 * 1024;
133
+
134
+ function parseSubProjects() {
135
+ const base = path.join(CWD, 'projects');
136
+ const out = [];
137
+ let looked = 0;
138
+ for (const name of safeListDir(base).sort()) { // sorted: the same render every time
139
+ if (looked >= SUB_SCAN_LIMIT) break;
140
+ looked += 1;
141
+ const dir = path.join(base, name);
142
+ if (!safeRun(() => fs.statSync(dir).isDirectory(), false)) continue; // a FILE named projects/x
143
+ const file = path.join(dir, '.claude', 'feature-roadmap.json');
144
+ const size = safeRun(() => fs.statSync(file).size, -1);
145
+ if (size < 0 || size > SUB_ROADMAP_MAX_BYTES) continue; // absent or absurd
146
+ const sum = summariseRoadmap(file);
147
+ if (sum) out.push({ name, ...sum }); // malformed → skipped
148
+ }
149
+ return out;
91
150
  }
92
151
 
93
152
  function parseSparcDocs() {
@@ -155,7 +214,7 @@ function parseInsights() {
155
214
  }
156
215
 
157
216
  function parseToolkit() {
158
- const dir = path.join(CWD, '.claude');
217
+ const dir = path.join(TOOLKIT_ROOT, '.claude');
159
218
  const skills = safeListDir(path.join(dir, 'skills')).filter((d) => {
160
219
  return safeRun(() => fs.statSync(path.join(dir, 'skills', d)).isDirectory(), false);
161
220
  }).length;
@@ -176,13 +235,13 @@ function parseExpectedToolkit() {
176
235
  skillsExpected: 10,
177
236
  commandsExpected: 11,
178
237
  agentsExpected: 4, // pre-shipped only (project agents are extra)
179
- rulesExpected: 5, // pre-shipped only (project rules are extra)
180
- hooksExpected: 6, // 4 v1.4.1 hooks + statusline + state-update (v1.5.0)
238
+ rulesExpected: 6, // pre-shipped only (project rules are extra)
239
+ hooksExpected: 9, // 4 v1.4.1 hooks + statusline + state-update + 3 deliberate checks
181
240
  };
182
241
  }
183
242
 
184
243
  function parseSettingsStatus(manifest) {
185
- const settingsPath = path.join(CWD, '.claude', 'settings.json');
244
+ const settingsPath = path.join(TOOLKIT_ROOT, '.claude', 'settings.json');
186
245
  if (!exists(settingsPath)) return 'missing';
187
246
  const cur = safeReadJson(settingsPath);
188
247
  if (!cur) return 'corrupt';
@@ -211,11 +270,53 @@ function parseKeysarium() {
211
270
  function parseDomain() {
212
271
  const claudeMd = safeReadText(path.join(CWD, 'CLAUDE.md'));
213
272
  if (!claudeMd) return null;
214
- // Heuristic keyword search
215
- const banking = /банк|bank|финт|fintech|gigachat|yandexgpt|ФЗ-152|ЦБ|ФСТЭК/i;
216
- const retail = /retail|e-commerce|ecommerce|ритейл|рекоменд|conversion/i;
217
- const enterprise = /enterprise|b2b|legacy|sla|change\s*management/i;
218
- const healthcare = /health|medical|клиник|hipaa|ФЗ-323/i;
273
+ // Heuristic keyword search, with UNICODE word boundaries.
274
+ //
275
+ // Without them this was wrong in four ways at once, all reproduced: "healthchecks" made a project
276
+ // medical, "translate" and "slack" made it enterprise (`sla` sits inside both), and "embankment"
277
+ // made it a bank.
278
+ //
279
+ // `\b` is NOT the fix and would quietly break the Russian half: it is defined over \w =
280
+ // [A-Za-z0-9_], so there is no word boundary between a space and `б`. MEASURED —
281
+ // /\bбанк/.test('банк России') is FALSE. Hence Unicode lookarounds with the `u` flag.
282
+ //
283
+ // Two kinds of term, deliberately distinguished. A STEM is meant to match inflections — `банк` in
284
+ // «банкинг», `финт` in «финтех», `рекоменд` in «рекомендательный» — so it takes a LEFT boundary
285
+ // only. A WHOLE WORD (`sla`, `bank`, `conversion`) takes both, because matching it inside another
286
+ // word is exactly the defect.
287
+ const L = '(?<![\\p{L}\\p{N}])'; // start of a word, in any alphabet
288
+ const R = '(?![\\p{L}\\p{N}])'; // end of a word
289
+ const stem = (alts) => new RegExp(L + '(?:' + alts + ')', 'iu');
290
+ const word = (alts) => new RegExp(L + '(?:' + alts + ')' + R, 'iu');
291
+
292
+ // A LEFT-only boundary is not enough for every Russian stem, and cross-family QE named the
293
+ // counter-examples: «Сервис организации банкетов» is not a bank, «Финты в футболе» is not fintech,
294
+ // «ЦБС городской библиотеки» is not the central bank. So the ambiguous stems are spelled out with
295
+ // their real inflections and right-bounded, and the two-letter acronym `цб` is dropped outright —
296
+ // two letters cannot be made safe by any boundary.
297
+ const bankingStem = stem('банковск|финтех|фз-152|фстэк');
298
+ const bankingWord = word('банк(?:а|у|ом|е|и|ов|ам|ами|ах|инг)?|bank(?:ing|s|er|ers)?'
299
+ + '|fintech|gigachat|yandexgpt');
300
+ // `retail` IS a stem — "Retailer inventory analytics" is retail and whole-word-only missed it.
301
+ // `conversion` alone is not: "Video conversion service" is not commerce. It needs its qualifier.
302
+ const retailStem = stem('ритейл|рекоменд|retail');
303
+ // `\\w` is [A-Za-z0-9_] and does NOT cover Cyrillic — the same trap as `\\b`, one level down:
304
+ // «конверси» + \\w* + a right boundary fails on «конверсию», because `ю` is neither \\w nor a
305
+ // boundary. Use \\p{L}. And the Russian term needs its qualifier for the same reason the English
306
+ // one does: «конверсия файлов» is no more commerce than "video conversion" is.
307
+ const retailWord = word('e-?commerce|conversion\\s+rate'
308
+ + '|конверси\\p{L}*\\s+(?:воронк|продаж|лид)\\p{L}*');
309
+ const enterpriseWord = word('enterprise(?:s)?|b2b|legacy|sla|change\\s*management');
310
+ // `health` alone cannot be made right: bounded it stops matching "healthcare", unbounded it matches
311
+ // "healthcheck". So it is replaced by the forms that actually mean the domain — including the
312
+ // space-separated "health tech", which the first version missed.
313
+ const healthcareStem = stem('медицин|клиник|фз-323');
314
+ const healthcareWord = word('health[\\s-]?(?:care|tech)|healthcare|medical|hipaa');
315
+
316
+ const banking = { test: (t) => bankingStem.test(t) || bankingWord.test(t) };
317
+ const retail = { test: (t) => retailStem.test(t) || retailWord.test(t) };
318
+ const enterprise = enterpriseWord;
319
+ const healthcare = { test: (t) => healthcareStem.test(t) || healthcareWord.test(t) };
219
320
  if (banking.test(claudeMd)) return 'banking';
220
321
  if (retail.test(claudeMd)) return 'retail';
221
322
  if (enterprise.test(claudeMd)) return 'enterprise';
@@ -281,16 +382,37 @@ function buildPipeline(state) {
281
382
  return parts.join(' ' + dim('│') + ' ');
282
383
  }
283
384
 
284
- function buildRoadmap(roadmap, domain) {
385
+ function buildRoadmap(roadmap, domain, subProjects) {
386
+ const subs = Array.isArray(subProjects) ? subProjects : [];
285
387
  if (!roadmap) {
388
+ // No roadmap at the root. If sub-projects have one, report THEIRS — labelled as theirs. The old
389
+ // line said "no roadmap yet" while thirteen features sat one directory down.
390
+ if (subs.length > 0) {
391
+ const total = subs.reduce((n, s) => n + s.total, 0);
392
+ const done = subs.reduce((n, s) => n + s.done, 0);
393
+ const where = subs.length === 1 ? subs[0].name : subs.length + ' projects';
394
+ return [
395
+ `🎯 ${bold('Roadmap')}`,
396
+ `${dim('in')} ${cyan(where)}`,
397
+ `${dotBar(done, total, 8)} ${dim('Done')} ${green(done + '/' + total)}`,
398
+ ].join(` ${dim('│')} `);
399
+ }
286
400
  return `🎯 ${bold('Roadmap')} ${dim('— no roadmap yet (run /next or /replicate)')}`;
287
401
  }
288
- const { total, done, inProgress, blocked, mvpTotal, mvpDone } = roadmap;
402
+ const { total, done, inProgress, blocked, mvpTotal, mvpDone, offSchemaCount } = roadmap;
289
403
  const dotbar = dotBar(done, total, 8);
290
404
  const parts = [
291
405
  `🎯 ${bold('Roadmap')}`,
292
- `${dotbar} mvp ${green(mvpDone + '/' + mvpTotal)}`,
293
- `${dim('Done')} ${green(done + '/' + total)}`,
406
+ // The bar is drawn from done/total, so it is captioned Done. It used to sit beside `mvp`, which
407
+ // it never showed one glyph and two different quantities, read as one statement. The bar is
408
+ // CORRECT and stays; the caption is what was wrong, and hiding a working indicator to mask a
409
+ // wrong caption would have been the wrong half of the fix.
410
+ `${dotbar} ${dim('Done')} ${green(done + '/' + total)}`,
411
+ offSchemaCount > 0
412
+ // The known count is still knowledge — replacing it with '?' throws away what WAS established
413
+ // and tells the reader less than before. Show both: what is counted, and how much was not.
414
+ ? `${dim('mvp')} ${green(mvpDone + '/' + mvpTotal)} ${yellow('⚠' + offSchemaCount)} ${dim('schema')}`
415
+ : `${dim('mvp')} ${green(mvpDone + '/' + mvpTotal)}`,
294
416
  ];
295
417
  if (inProgress) {
296
418
  parts.push(`${dim('▶')} ${cyan(inProgress.id)}`);
@@ -298,6 +420,10 @@ function buildRoadmap(roadmap, domain) {
298
420
  if (blocked > 0) {
299
421
  parts.push(`${red('Blocked')} ${blocked}`);
300
422
  }
423
+ if (subs.length > 0) {
424
+ const subTotal = subs.reduce((n, s) => n + s.total, 0);
425
+ parts.push(`${dim('+' + subs.length + ' sub')} ${green(String(subTotal))}`);
426
+ }
301
427
  if (domain) {
302
428
  parts.push(`${dim('Domain:')} ${cyan(domain)}`);
303
429
  }
@@ -367,6 +493,7 @@ function main() {
367
493
  const manifest = safeRun(() => parseManifest(), null);
368
494
  const state = safeRun(() => parseState(), null);
369
495
  const roadmap = safeRun(() => parseRoadmap(), null);
496
+ const subProjects = safeRun(() => parseSubProjects(), []);
370
497
  const sparc = safeRun(() => parseSparcDocs(), { present: 0, total: 11 });
371
498
  const validation = safeRun(() => parseValidationScore(), null);
372
499
  const adrs = safeRun(() => parseAdrs(), 0);
@@ -384,7 +511,7 @@ function main() {
384
511
  const lines = [
385
512
  buildHeader(manifest),
386
513
  buildPipeline(state),
387
- buildRoadmap(roadmap, domain),
514
+ buildRoadmap(roadmap, domain, subProjects),
388
515
  buildDocs(sparc, validation, plans, adrs, lastHarvest),
389
516
  buildToolkit(toolkit, expected),
390
517
  buildStatus(insights, lastTest, mcpServers, settingsStatus, keysarium),
@@ -0,0 +1,123 @@
1
+ # Docker-порты: что смотрит наружу, а что нет
2
+
3
+ ## Правило №0 — хранилище не публикуется в интернет
4
+
5
+ **Инвариант:** у хранилища НЕТ публикации на хост — кроме привязки к петле. Postgres, MySQL,
6
+ MariaDB, MongoDB, Redis, Elasticsearch/OpenSearch, MinIO, RabbitMQ, Memcached, ClickHouse.
7
+
8
+ Формулировка «не на все интерфейсы» была бы слишком узкой и пропускала бы половину случаев. Запрещено
9
+ всё это, а не только первое:
10
+
11
+ | Строка | Почему нарушение |
12
+ |---|---|
13
+ | `"5432:5432"` | без адреса Docker привязывает ко ВСЕМ интерфейсам |
14
+ | `"0.0.0.0:5432:5432"` | то же самое, явно |
15
+ | `"[::]:5432:5432"` | IPv6-джокер — та же дыра, часто забывают |
16
+ | `"203.0.113.7:5432:5432"` | ОДИН явный публичный адрес: не «все интерфейсы», но из интернета доступно ровно так же |
17
+ | `network_mode: host` | публикации нет вовсе, а порт всё равно наружу: контейнер слушает прямо на хосте. Хранилищу этот режим не подходит |
18
+
19
+ Разрешено ровно два состояния: публикации нет, либо она привязана к петле (`127.0.0.1` или `::1`).
20
+
21
+ **Почему это правило №0, а не пожелание.** Тестовый Postgres, поднятый с `-p 55432:5432` и паролем
22
+ `postgres`, был взломан из интернета примерно за час: через `COPY … TO PROGRAM` на машину въехал
23
+ червь-майнер. Порт был открыт «на время отладки». Цена ошибки здесь — компрометация машины, а не
24
+ неудобство, поэтому правило безусловное.
25
+
26
+ ## Что писать вместо
27
+
28
+ Достаточно **убрать `ports:` целиком**:
29
+
30
+ ```yaml
31
+ services:
32
+ db:
33
+ image: postgres:16
34
+ # никакого ports: — и это ПОЛНОЕ решение
35
+ ```
36
+
37
+ **Соседи по сети compose достучатся до сервиса по его имени** (`db:5432`) без всякой публикации:
38
+ внутри сети видны все порты, которые контейнер слушает. Публикация нужна только для доступа С ХОСТА,
39
+ а не между контейнерами.
40
+
41
+ `expose:` в современном Compose ничего не открывает и ничего не требуется — это документирующая
42
+ запись, не более:
43
+
44
+ ```yaml
45
+ expose:
46
+ - "5432" # необязательно; читается как «этот сервис слушает 5432»
47
+ ```
48
+
49
+ Писать её можно, полагаться на неё как на «то, что включает доступ» — нельзя: доступ внутри сети есть
50
+ и без неё.
51
+
52
+ ## Исключение: петля — это законно
53
+
54
+ Привязка к петлевому адресу разрешена и является нормальным способом поднять тестовое окружение,
55
+ где тесты идут с хоста:
56
+
57
+ ```yaml
58
+ ports:
59
+ - "127.0.0.1:55432:5432" # ЗАКОННО: доступно только с самой машины
60
+ ```
61
+
62
+ Петлевых форм две и они равноправны: `127.0.0.1:` и `[::1]:`. Всё остальное — нарушение, включая
63
+ `0.0.0.0`, `[::]`, отсутствие адреса вообще и один явный внешний адрес. Docker показывает привязку
64
+ как `host_ip:`; «доступно мне» от «доступно всем» отличает именно она.
65
+
66
+ Правило без этого исключения запрещало бы ровно то, что предписывает сам конвейер, поэтому исключение
67
+ такая же часть правила, как и запрет.
68
+
69
+ ## За reverse-proxy приложение тоже не публикуется
70
+
71
+ Если в compose есть `caddy`/`nginx`/`traefik`, то он и есть единственная дверь. Опубликованное рядом
72
+ приложение позволяет обойти прокси — а вместе с ним и всё, что прокси гарантирует: TLS,
73
+ rate-limiting, заголовки. Известный конкретный случай: код, доверяющий последнему элементу
74
+ `X-Forwarded-For`, потому что его дописывает прокси, при прямом доступе получает этот элемент от
75
+ клиента — и лимит обходится сменой заголовка.
76
+
77
+ ## Чем это правило НЕ является
78
+
79
+ **Проверка теперь есть — но она не запускается сама.** В пакете отгружается
80
+ `.claude/hooks/check-ports.cjs`: он читает нормализованный конфиг (`docker compose config`) и
81
+ проверяет ровно этот инвариант, включая `network_mode: host` и обход reverse-proxy.
82
+
83
+ ```bash
84
+ node .claude/hooks/check-ports.cjs . # или путь к конкретному compose-файлу
85
+ ```
86
+
87
+ Коды возврата — три, и третий здесь главный: `0` правило соблюдено, `1` нарушено, **`2` проверка НЕ
88
+ ВЫПОЛНЕНА** (нет compose, нет docker, конфиг не читается). Проверка, которая на нечитаемом конфиге
89
+ отвечает «чисто», хуже отсутствия проверки: она превращает неизвестность в заверение.
90
+
91
+ **Чего она НЕ делает.** Она не привязана ни к какому событию — её надо позвать. Она смотрит на ВАШ
92
+ проект, а не на машину: конфликты с портами, уже занятыми другими контейнерами, она не ищет. И она
93
+ проверяет конфиг, а не запущенный стек.
94
+
95
+ Детерминированная проверка существует отдельно (`check-port-conflicts.sh`, «Правило №0») и в этот
96
+ пакет не входит. Причина: она на bash, а ХУКИ пакета принципиально на кросс-платформенном Node —
97
+ ради Windows, — и проверка портов по форме именно хук. (Точности ради: пара вспомогательных `.sh`
98
+ в пакете уже есть, внутри навыка `brutal-honesty-review`; обещание Node относится к хукам, а не ко
99
+ всему пакету.) Если проверка вам нужна, её надо взять и подключить осознанно.
100
+
101
+ Не выводите из присутствия этого правила, что кто-то за вас смотрит. Пока проверки нет, единственный
102
+ контроль — глаза на ревью, и знать об этом важнее, чем иметь правило.
103
+
104
+ ## Быстрая самопроверка
105
+
106
+ ```bash
107
+ docker compose config
108
+ ```
109
+
110
+ Смотреть надо весь нормализованный вывод, а не окно вокруг `published`: Docker печатает `host_ip`
111
+ отдельной строкой, и она не обязана попасть в фиксированные `-A2` — проверка по окну молча пропустит
112
+ нарушение.
113
+
114
+ Для каждого сервиса-хранилища ответьте на два вопроса:
115
+
116
+ 1. есть ли у него `published:` (или `network_mode: host`)?
117
+ 2. если есть — стоит ли рядом `host_ip: 127.0.0.1` либо `host_ip: ::1`?
118
+
119
+ «Да» на первый и «нет» на второй — нарушение правила №0. Обе петлевые формы равноправны: проверка
120
+ только по `127.0.0.1` отвергла бы `::1`, который это же правило разрешает.
121
+
122
+ Детерминированная версия этой проверки существует и разбирает вывод с отслеживанием состояния именно
123
+ потому, что построчного окна недостаточно.