@dzhechkov/p-replicator 1.5.17 → 1.6.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 (85) hide show
  1. package/.dz-manifest.json +173 -65
  2. package/CHANGELOG.md +135 -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 +41 -18
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +347 -77
  19. package/src/utils.js +2 -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 +126 -8
  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-ports.cjs +232 -0
  29. package/templates/.claude/hooks/session-insights.cjs +13 -1
  30. package/templates/.claude/hooks/state-update.cjs +13 -1
  31. package/templates/.claude/hooks/statusline.cjs +145 -18
  32. package/templates/.claude/rules/docker-ports.md +123 -0
  33. package/templates/.claude/rules/replicate-pipeline.md +5 -2
  34. package/templates/.claude/settings.json +5 -5
  35. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  36. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  37. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  44. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
  45. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
  46. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
  47. package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
  48. package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
  49. package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
  50. package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
  51. package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
  52. package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
  53. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
  54. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
  55. package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
  56. package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
  57. package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
  58. package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
  59. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
  60. package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
  61. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  62. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  63. package/tests/e2e/lifecycle.test.js +21 -10
  64. package/tests/snapshot/baseline.json +51 -34
  65. package/tests/snapshot/update-baseline.js +2 -1
  66. package/tests/unit/adr-decision-coverage.test.js +137 -0
  67. package/tests/unit/adr-scanner-contract.test.js +108 -0
  68. package/tests/unit/autocommit-deletion.test.js +242 -0
  69. package/tests/unit/check-ports.test.js +184 -0
  70. package/tests/unit/db-port-rule.test.js +216 -0
  71. package/tests/unit/detect-parse-anchor.test.js +109 -0
  72. package/tests/unit/external-dependency-check.test.js +209 -0
  73. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  74. package/tests/unit/hooks-project-anchored.test.js +223 -0
  75. package/tests/unit/hooks-report-failures.test.js +207 -0
  76. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  77. package/tests/unit/roadmap-one-schema.test.js +179 -0
  78. package/tests/unit/sparc-reconciliation.test.js +117 -0
  79. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  80. package/tests/unit/statusline-honest-labels.test.js +178 -0
  81. package/tests/unit/statusline-two-roots.test.js +237 -0
  82. package/tests/unit/sync-templates-guard.test.js +209 -0
  83. package/tests/unit/utils.test.js +2 -2
  84. package/tests/unit/validation-gate-teeth.test.js +158 -0
  85. package/LICENSE +0 -21
@@ -10,35 +10,118 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
 
13
- const TARGET = path.resolve(process.cwd(), '.claude', 'feature-roadmap.json');
14
- const RELATIVE = path.relative(process.cwd(), TARGET);
15
- const SILENT = { stdio: 'ignore' };
13
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
14
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
15
+ // first the host is authoritative about what the project is. `__dirname` second: a hook always
16
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
17
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
18
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
19
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
20
+ // cwd, which is the very bug this anchor exists to remove.
21
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
22
+ ? ENV_ROOT
23
+ : path.resolve(__dirname, '..', '..');
24
+
25
+ const TARGET = path.resolve(ROOT, '.claude', 'feature-roadmap.json');
26
+ const RELATIVE = path.relative(ROOT, TARGET);
27
+ // cwd: ROOT — the paths below are relative to ROOT, so git must run there too.
28
+ // stderr is PIPED, not ignored: on success nothing is printed anyway, and on failure git's
29
+ // own words are the only thing that tells a reader WHY. Discarding them leaves a report that
30
+ // names what failed and not why, which is half a report.
31
+ const SILENT = { stdio: ['ignore', 'pipe', 'pipe'], cwd: ROOT };
32
+
33
+ // What the hook was DOING when it failed. The outer catch also sees staging failures and a
34
+ // missing git binary, and reporting either of those as "could not commit" would send the reader
35
+ // looking in the wrong place.
36
+ let stage = 'start';
16
37
 
17
38
  function git(args) {
18
39
  return execFileSync('git', args, SILENT);
19
40
  }
20
41
 
21
42
  try {
22
- // Skip if file doesn't exist or we're not in a git repo.
23
- if (!fs.existsSync(TARGET)) process.exit(0);
24
- try { git(['rev-parse', '--git-dir']); } catch { process.exit(0); }
43
+ // The repository probe comes FIRST now, because the absence check below needs to ask git a
44
+ // question and there is no point asking outside a repository.
45
+ stage = 'inspect the repository';
46
+ try {
47
+ git(['rev-parse', '--git-dir']);
48
+ } catch (probeErr) {
49
+ // "Not a git repository" is the ordinary case and stays silent. Everything else — no git on
50
+ // PATH, dubious ownership, a permission error — is a real failure that used to look exactly
51
+ // like it, which is how a broken machine and an ordinary directory became indistinguishable.
52
+ const why = String((probeErr && probeErr.stderr) || '').trim();
53
+ if (!why || /not a git repository/i.test(why)) process.exit(0);
54
+ throw probeErr;
55
+ }
56
+
57
+ // Absent — but absent HOW? A path that never existed is nothing to do, as before. A path git
58
+ // still tracks is a DELETION, and a deletion is exactly the change this hook exists to record.
59
+ //
60
+ // `git ls-files` is the discriminator, and it must run BEFORE staging: staging a deletion
61
+ // removes the entry from the index, after which the same question answers "not tracked".
62
+ // CORRECTION, measured: `--error-unmatch` also discriminates correctly here, for files AND for
63
+ // directories — 0 for a deleted-but-tracked path, 1 for one that never existed. An earlier note
64
+ // here claimed otherwise; that claim came from measuring it AFTER staging, which is what made it
65
+ // fail, not the directory-ness. `ls-files --` is preferred only because it answers with DATA
66
+ // (empty or not) instead of by throwing, so the ordinary case needs no exception handling.
67
+ //
68
+ // Simply DELETING the existence guard would have been the naive fix and would have broken the
69
+ // reporting that shipped yesterday: `git add` on a path that never existed exits 128, so every
70
+ // session stop in every project without this artifact would print a failure line. MEASURED.
71
+ if (!fs.existsSync(TARGET)) {
72
+ // A bare `catch { tracked = '' }` here would turn a REAL git failure into "never existed"
73
+ // and skip a deletion that should have been recorded — a silent bypass of the very thing
74
+ // this feature adds. A failure is rethrown into the reporting path; only a genuinely
75
+ // empty answer means the artifact never lived here.
76
+ const tracked = String(git(['ls-files', '--', RELATIVE]) || '').trim();
77
+ if (!tracked) process.exit(0); // never existed here — silent, exactly as before
78
+ }
25
79
 
26
- // Stage only the target file.
80
+ // Stage the target (or its removal).
81
+ stage = 'stage the change';
27
82
  git(['add', '--', RELATIVE]);
28
83
 
29
84
  // Check whether anything is staged for THIS path.
30
85
  // `git diff --cached --quiet -- <path>` exits 0 = no diff, 1 = diff exists.
31
- let hasDiff = false;
86
+ // Ask git WHAT it staged, not merely WHETHER something changed — same subprocess, strictly
87
+ // more information. Deriving "this is a removal" from whether the path still exists was
88
+ // wrong for a directory: deleting ONE file inside it, or deleting every tracked file while
89
+ // an ignored one keeps the directory present, would have been committed as an update and
90
+ // defeated the very search this feature promises.
91
+ let staged = '';
32
92
  try {
33
- git(['diff', '--cached', '--quiet', '--', RELATIVE]);
93
+ staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
34
94
  } catch {
35
- hasDiff = true;
95
+ // No HEAD yet (an unborn repository): there is no history to record a removal against.
96
+ staged = '';
36
97
  }
98
+ const deleted = staged.split('\n').some((l) => /^D/.test(l.trim()));
99
+ const hasDiff = staged.length > 0;
37
100
 
38
101
  if (hasDiff) {
39
- git(['commit', '--only', '--', RELATIVE, '-m', 'docs(roadmap): auto-update']);
102
+ // -m BEFORE the `--`: everything after `--` is a PATHSPEC, so the old order made git
103
+ // look for files literally named '-m' and 'docs(roadmap): auto-update' — it failed every
104
+ // time, from every directory, and this hook exits 0 on failure, so nobody saw it.
105
+ stage = 'commit';
106
+ // A deletion gets its own subject, so the event is findable in `git log` without
107
+ // reading diffs — which is the whole point of recording it.
108
+ git(['commit', '-m', deleted ? 'docs(roadmap): auto-remove' : 'docs(roadmap): auto-update', '--only', '--', RELATIVE]);
40
109
  }
41
- } catch (_err) {
42
- // Best-effort — never break Claude session on commit failures.
110
+ } catch (err) {
111
+ // Best-effort — never break the Claude session on commit failures. But "not breaking the session"
112
+ // and "saying nothing" are different things, and only the first one is the contract: it is about
113
+ // the EXIT CODE. Silence is what let a permanent defect live here undetected — `-m` after `--`
114
+ // made every commit fail, from every directory, forever, and nothing said so.
115
+ //
116
+ // A held index.lock, a missing user.email, a repository pre-commit hook that rejected the commit,
117
+ // an ignored target: each now costs one line and still exits 0. The ordinary "nothing to commit"
118
+ // path never reaches here, deliberately — a notice that fires when nothing is wrong trains people
119
+ // to ignore notices, and the next real failure scrolls past with them.
120
+ const artifactName = '.claude/feature-roadmap.json';
121
+ const gitSaid = err && err.stderr ? String(err.stderr).trim() : '';
122
+ const detail = (gitSaid || String((err && err.message) || err)).split('\n')
123
+ .map((l) => l.trim()).filter(Boolean)[0] || 'unknown error';
124
+ process.stdout.write('[autocommit-roadmap] could not ' + stage + ' — ' + artifactName + ': '
125
+ + detail.slice(0, 200) + '\n');
43
126
  process.exit(0);
44
127
  }
@@ -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: 7, // 4 v1.4.1 hooks + statusline + state-update + check-ports
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),