@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.
- package/.dz-manifest.json +173 -65
- package/CHANGELOG.md +135 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +6 -2
- package/README/eng/README.md +1 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +1 -1
- package/README/ru/html/index.html +7 -7
- package/README.md +41 -18
- package/bin/cli.js +0 -0
- package/package.json +11 -10
- package/sbom.json +347 -77
- package/src/utils.js +2 -0
- package/templates/.claude/agents/doc-validator.md +2 -1
- package/templates/.claude/agents/product-discoverer.md +1 -1
- package/templates/.claude/commands/next.md +16 -0
- package/templates/.claude/commands/replicate.md +126 -8
- package/templates/.claude/commands/start.md +19 -1
- package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
- package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
- package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
- package/templates/.claude/hooks/check-ports.cjs +232 -0
- package/templates/.claude/hooks/session-insights.cjs +13 -1
- package/templates/.claude/hooks/state-update.cjs +13 -1
- package/templates/.claude/hooks/statusline.cjs +145 -18
- package/templates/.claude/rules/docker-ports.md +123 -0
- package/templates/.claude/rules/replicate-pipeline.md +5 -2
- package/templates/.claude/settings.json +5 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +340 -47
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +359 -3
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +386 -13
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixture_legacy_v2_fact.json +23 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/fixtures_field_cases.json +133 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/goap_planner.py +314 -44
- package/templates/.claude/skills/goap-research-ed25519/scripts/learning_bridge.py +890 -303
- package/templates/.claude/skills/goap-research-ed25519/scripts/population_match.py +591 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/risk_statement.py +289 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +57 -2
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +969 -344
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_goap_planner.py +420 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_population_match.py +544 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_risk_absolute.py +239 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_signature_v3.py +554 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +90 -0
- package/templates/.claude/skills/requirements-validator/SKILL.md +7 -0
- package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
- package/tests/e2e/lifecycle.test.js +21 -10
- package/tests/snapshot/baseline.json +51 -34
- package/tests/snapshot/update-baseline.js +2 -1
- package/tests/unit/adr-decision-coverage.test.js +137 -0
- package/tests/unit/adr-scanner-contract.test.js +108 -0
- package/tests/unit/autocommit-deletion.test.js +242 -0
- package/tests/unit/check-ports.test.js +184 -0
- package/tests/unit/db-port-rule.test.js +216 -0
- package/tests/unit/detect-parse-anchor.test.js +109 -0
- package/tests/unit/external-dependency-check.test.js +209 -0
- package/tests/unit/growth-module-b2b-gate.test.js +104 -0
- package/tests/unit/hooks-project-anchored.test.js +223 -0
- package/tests/unit/hooks-report-failures.test.js +207 -0
- package/tests/unit/pipeline-file-ownership.test.js +95 -0
- package/tests/unit/roadmap-one-schema.test.js +179 -0
- package/tests/unit/sparc-reconciliation.test.js +117 -0
- package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
- package/tests/unit/statusline-honest-labels.test.js +178 -0
- package/tests/unit/statusline-two-roots.test.js +237 -0
- package/tests/unit/sync-templates-guard.test.js +209 -0
- package/tests/unit/utils.test.js +2 -2
- package/tests/unit/validation-gate-teeth.test.js +158 -0
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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', '--
|
|
93
|
+
staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
|
|
34
94
|
} catch {
|
|
35
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
const r = safeReadJson(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
180
|
-
hooksExpected:
|
|
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(
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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),
|