@dzhechkov/p-replicator 1.5.18 → 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 (69) hide show
  1. package/.dz-manifest.json +126 -46
  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 +36 -15
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +245 -45
  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/requirements-validator/SKILL.md +7 -0
  45. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  46. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +143 -1
  47. package/tests/e2e/lifecycle.test.js +21 -10
  48. package/tests/snapshot/baseline.json +29 -27
  49. package/tests/snapshot/update-baseline.js +2 -1
  50. package/tests/unit/adr-decision-coverage.test.js +137 -0
  51. package/tests/unit/adr-scanner-contract.test.js +108 -0
  52. package/tests/unit/autocommit-deletion.test.js +242 -0
  53. package/tests/unit/check-ports.test.js +184 -0
  54. package/tests/unit/db-port-rule.test.js +216 -0
  55. package/tests/unit/detect-parse-anchor.test.js +109 -0
  56. package/tests/unit/external-dependency-check.test.js +209 -0
  57. package/tests/unit/growth-module-b2b-gate.test.js +104 -0
  58. package/tests/unit/hooks-project-anchored.test.js +223 -0
  59. package/tests/unit/hooks-report-failures.test.js +207 -0
  60. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  61. package/tests/unit/roadmap-one-schema.test.js +179 -0
  62. package/tests/unit/sparc-reconciliation.test.js +117 -0
  63. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  64. package/tests/unit/statusline-honest-labels.test.js +178 -0
  65. package/tests/unit/statusline-two-roots.test.js +237 -0
  66. package/tests/unit/sync-templates-guard.test.js +209 -0
  67. package/tests/unit/utils.test.js +2 -2
  68. package/tests/unit/validation-gate-teeth.test.js +158 -0
  69. package/LICENSE +0 -21
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ // Each autocommit hook ended in a bare swallow — `catch (_err) { process.exit(0); }` — and the
4
+ // comment beside it said "never break Claude session on commit failures". The goal was right and the
5
+ // method was wrong: NOT BREAKING THE SESSION and SAYING NOTHING are different things, and only the
6
+ // first one is the contract, because the contract is about the exit code.
7
+ //
8
+ // This is not a hypothetical class. It is the class that hid a permanent defect: git commit put -m
9
+ // AFTER the `--`, so git read the message as a pathspec and every commit failed, from every
10
+ // directory, forever. Three hooks had never committed anything and nothing said so. It surfaced on
11
+ // 2026-08-26 only because a test asserted an EFFECT rather than an exit code.
12
+ //
13
+ // NOT changed here, and left to the owner: whether a DELETED target should be committed. That is a
14
+ // product question — is removing your roadmap something you want auto-committed? — and it is filed.
15
+
16
+ const { test, describe } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const { execFileSync } = require('node:child_process');
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
24
+
25
+ const HOOKS = [
26
+ { name: 'autocommit-roadmap', artifact: '.claude/feature-roadmap.json',
27
+ write: (root) => {
28
+ fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
29
+ return { file: path.join(root, '.claude', 'feature-roadmap.json'), body: '{"v":1}\n' };
30
+ } },
31
+ { name: 'autocommit-insights', artifact: '.claude/insights/',
32
+ write: (root) => {
33
+ fs.mkdirSync(path.join(root, '.claude', 'insights'), { recursive: true });
34
+ return { file: path.join(root, '.claude', 'insights', 'index.md'), body: '## one\n' };
35
+ } },
36
+ { name: 'autocommit-plans', artifact: 'docs/plans/',
37
+ write: (root) => {
38
+ fs.mkdirSync(path.join(root, 'docs', 'plans'), { recursive: true });
39
+ return { file: path.join(root, 'docs', 'plans', 'a.md'), body: '# plan\n' };
40
+ } },
41
+ ];
42
+
43
+ /** A project with the hooks installed. `identity` decides whether git can commit at all. */
44
+ function project(opts) {
45
+ const o = opts || {};
46
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-report-')));
47
+ fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
48
+ for (const h of HOOKS) {
49
+ fs.copyFileSync(path.join(TPL, 'hooks', h.name + '.cjs'),
50
+ path.join(root, '.claude', 'hooks', h.name + '.cjs'));
51
+ }
52
+ if (o.git !== false) {
53
+ const git = (args) => execFileSync('git', args, { cwd: root, stdio: 'ignore' });
54
+ git(['init', '-q']);
55
+ // An EMPTY identity is a real, common failure: git refuses to commit and says why. It is the
56
+ // cheapest way to drive the failure path without breaking the fixture in an artificial way.
57
+ git(['config', 'user.email', o.identity === false ? '' : 'test@example.invalid']);
58
+ git(['config', 'user.name', o.identity === false ? '' : 'test']);
59
+ }
60
+ return root;
61
+ }
62
+
63
+ /** Run a hook and capture BOTH streams. The stream matters: MEASURED in the Claude Code binary,
64
+ * a hook exiting 0 has its stderr discarded ("Exit code 0 - stdout/stderr not shown") while stdout
65
+ * can be surfaced ("Exit code 0 - stdout shown in transcript mode (ctrl+o)"). The first version of
66
+ * this feature wrote its notice to stderr — the one stream guaranteed to be invisible. */
67
+ function runHook(root, hookName) {
68
+ const script = path.join(root, '.claude', 'hooks', hookName + '.cjs');
69
+ const env = Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root });
70
+ const out = require('node:child_process').spawnSync(process.execPath, [script],
71
+ { cwd: root, env, encoding: 'utf8' });
72
+ return { code: out.status, stdout: out.stdout || '', stderr: out.stderr || '' };
73
+ }
74
+
75
+ const cleanup = (d) => fs.rmSync(d, { recursive: true, force: true });
76
+
77
+ describe('an autocommit hook that cannot commit says so, once, without blocking', () => {
78
+ test('P1 — a real git failure is REPORTED, and the exit code is still 0', () => {
79
+ const root = project({ identity: false });
80
+ try {
81
+ const h = HOOKS[0];
82
+ const t = h.write(root);
83
+ fs.writeFileSync(t.file, t.body);
84
+ const r = runHook(root, h.name);
85
+ assert.equal(r.code, 0,
86
+ 'the non-blocking contract is about the EXIT CODE and must be untouched');
87
+ assert.ok(r.stdout.includes('[' + h.name + ']'),
88
+ 'the line must name the hook, or the reader cannot tell which artifact is uncommitted: '
89
+ + r.stdout);
90
+ assert.ok(r.stdout.includes(h.artifact),
91
+ 'and name the artifact: ' + r.stdout);
92
+ // WHY, not just what. git's own words are the only thing that says why, and they are only
93
+ // available because stderr is piped rather than ignored.
94
+ assert.match(r.stdout, /identity/i,
95
+ "git's own reason must survive into the message: " + r.stdout);
96
+ assert.equal(r.stdout.trim().split('\n').length, 1,
97
+ 'exactly one line — a hook that floods output is a hook people silence: ' + r.stdout);
98
+ assert.equal(r.stderr.trim(), '',
99
+ 'the notice must NOT go to stderr: a hook exiting 0 has its stderr discarded, so writing '
100
+ + 'there is writing nowhere');
101
+ // The stage must be accurate. The outer catch also sees staging failures and a missing git
102
+ // binary, and calling either of those "could not commit" sends the reader to the wrong place.
103
+ assert.match(r.stdout, /could not commit/,
104
+ 'this fixture fails AT the commit, so that is what the line must say: ' + r.stdout);
105
+ } finally { cleanup(root); }
106
+ });
107
+
108
+ test('P2 — nothing to commit is SILENT: a notice that cries wolf gets ignored', () => {
109
+ // The ordinary case, on most sessions, for all three hooks. If it printed, the next REAL failure
110
+ // would scroll past with the noise.
111
+ const root = project({});
112
+ try {
113
+ const h = HOOKS[0];
114
+ const t = h.write(root);
115
+ fs.writeFileSync(t.file, t.body);
116
+ execFileSync('git', ['add', '-A'], { cwd: root, stdio: 'ignore' });
117
+ execFileSync('git', ['commit', '-qm', 'base'], { cwd: root, stdio: 'ignore' });
118
+ const r = runHook(root, h.name);
119
+ assert.equal(r.code, 0);
120
+ assert.equal(r.stdout.trim(), '',
121
+ 'the ordinary no-op path must stay silent: ' + r.stdout);
122
+ } finally { cleanup(root); }
123
+ });
124
+
125
+ test('P3 — not a git repository at all: silent, exit 0', () => {
126
+ const root = project({ git: false });
127
+ try {
128
+ const h = HOOKS[0];
129
+ const t = h.write(root);
130
+ fs.writeFileSync(t.file, t.body);
131
+ const r = runHook(root, h.name);
132
+ assert.equal(r.code, 0);
133
+ assert.equal(r.stdout.trim(), '', 'not-a-repo is not a failure to report: ' + r.stdout);
134
+ } finally { cleanup(root); }
135
+ });
136
+
137
+ test('P4 — the target is absent: silent, exit 0', () => {
138
+ const root = project({});
139
+ try {
140
+ const r = runHook(root, HOOKS[0].name);
141
+ assert.equal(r.code, 0);
142
+ assert.equal(r.stdout.trim(), '',
143
+ 'an absent artifact is nothing to commit, not a failure: ' + r.stdout);
144
+ } finally { cleanup(root); }
145
+ });
146
+
147
+ test('P7 — a repository probe failure other than a plain non-repo is reported', () => {
148
+ // Every rev-parse failure used to be read as "not a git repository", so a machine with no git on
149
+ // PATH, a dubious-ownership refusal, or a permission error looked exactly like an ordinary
150
+ // non-repo directory. Two very different facts, one silence.
151
+ const root = project({ git: false });
152
+ try {
153
+ const h = HOOKS[0];
154
+ const t = h.write(root);
155
+ fs.writeFileSync(t.file, t.body);
156
+ // A directory owned by nobody this process can vouch for is hard to fake portably; the
157
+ // distinction itself is asserted on the source, and the ordinary case is proven live by P3.
158
+ const src = fs.readFileSync(path.join(TPL, 'hooks', h.name + '.cjs'), 'utf-8');
159
+ assert.match(src, /not a git repository/i,
160
+ 'the ordinary case must be recognised by NAME, not by "any failure here is ordinary"');
161
+ assert.match(src, /throw probeErr/,
162
+ 'and everything else must be re-thrown into the reporting path');
163
+ assert.equal(runHook(root, h.name).code, 0, 'and the ordinary case still exits 0');
164
+ } finally { cleanup(root); }
165
+ });
166
+
167
+ test('P5 — all three hooks behave identically', () => {
168
+ // They are the same shape; a difference between them would be a second thing to remember.
169
+ for (const h of HOOKS) {
170
+ const root = project({ identity: false });
171
+ try {
172
+ const t = h.write(root);
173
+ fs.writeFileSync(t.file, t.body);
174
+ const r = runHook(root, h.name);
175
+ assert.equal(r.code, 0, h.name + ' must exit 0');
176
+ assert.ok(r.stdout.includes('[' + h.name + ']'),
177
+ h.name + ' must report the failure too: ' + r.stdout);
178
+ } finally { cleanup(root); }
179
+ }
180
+ });
181
+
182
+ test('P6 — no hook still carries the bare swallow', () => {
183
+ for (const h of HOOKS) {
184
+ const src = fs.readFileSync(path.join(TPL, 'hooks', h.name + '.cjs'), 'utf-8');
185
+ assert.ok(!/catch \(_err\) \{\s*(\/\/[^\n]*\n\s*)*process\.exit\(0\);\s*\}/.test(src),
186
+ h.name + ' still swallows every failure without a word');
187
+ assert.match(src, /process\.stdout\.write/,
188
+ h.name + ' must report on stdout — stderr on exit 0 is discarded, so it is not a channel');
189
+ assert.ok(!/process\.stderr\.write/.test(src),
190
+ h.name + ' must not write its notice to the stream nobody reads');
191
+ assert.match(src, /let stage = 'start'/,
192
+ h.name + ' must track WHICH operation failed, or "could not commit" is claimed for a '
193
+ + 'staging failure and a missing git binary too');
194
+ // The reason is only available because git's stderr is piped. Ignoring it leaves a message
195
+ // that names what failed and not why, which is half a report.
196
+ // The PROPERTY is that git's stderr is captured, not the exact array. The first version pinned
197
+ // ['ignore','ignore','pipe'] literally and went red when stdout was later piped too — for a
198
+ // different feature that needed to READ a git answer. Pinning a shape instead of a property
199
+ // makes a compatible change look like a regression.
200
+ const stdio = src.match(/const SILENT = \{ stdio: (\[[^\]]*\])/);
201
+ assert.ok(stdio, h.name + ' must configure git stdio explicitly');
202
+ const streams = JSON.parse(stdio[1].replace(/'/g, '"'));
203
+ assert.equal(streams[2], 'pipe',
204
+ h.name + " must capture git's own words on stderr rather than discard them: " + stdio[1]);
205
+ }
206
+ });
207
+ });
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // /replicate and /start both write docker-compose.yml and .gitignore — on the RECOMMENDED path.
4
+ //
5
+ // `replicate.md` Phase 4 FINALIZE generates docker-compose.yml (from Architecture.md services),
6
+ // Dockerfile and .gitignore; `start.md` Phase 1 generated package.json, docker-compose.yml,
7
+ // .env.example, .gitignore and tsconfig.base.json with no existence guard at all. And
8
+ // `replicate.md` itself says "Run /start to bootstrap the project" — so the collision was the normal
9
+ // sequence, not an edge case.
10
+ //
11
+ // The field report proposed splitting ownership "by origin": architecture-derived to /replicate,
12
+ // build-derived to /start. That cannot work here, because BOTH derive compose from the SAME source —
13
+ // replicate.md from "Architecture.md services", start.md from "docs/Architecture.md → monorepo
14
+ // structure, Docker Compose, tech stack". One artifact, derived twice; an origin split has nothing
15
+ // to split on. So the guard is existence-plus-stated-change, which closes the silent loss without
16
+ // deciding who owns the file — that ownership question is deliberately left to the owner.
17
+ //
18
+ // These are PROMPT files a model executes, so the deterministic layer available is their content.
19
+ // Every assertion below is DISCRIMINATING: remove what it names and it goes red.
20
+
21
+ const { test, describe } = require('node:test');
22
+ const assert = require('node:assert/strict');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const CMDS = path.join(__dirname, '..', '..', 'templates', '.claude', 'commands');
27
+ const read = (f) => fs.readFileSync(path.join(CMDS, f), 'utf-8');
28
+
29
+ /** Phase 1 of start.md — placement matters: a guard in a later phase guards nothing. */
30
+ function startPhase1(src) {
31
+ const start = src.indexOf('### Phase 1: Foundation');
32
+ const end = src.indexOf('### Phase 2');
33
+ assert.ok(start > 0, 'start.md must have a Phase 1');
34
+ assert.ok(end > start, 'start.md must have a Phase 2 after it');
35
+ return src.slice(start, end);
36
+ }
37
+
38
+ describe('/start must not silently discard what /replicate wrote (PR-008)', () => {
39
+ test('P1 — Phase 1 names BOTH overlapping files under the guard', () => {
40
+ const block = startPhase1(read('start.md'));
41
+ assert.match(block, /docker-compose\.yml/, 'the compose file must be named');
42
+ assert.match(block, /\.gitignore/, 'the gitignore must be named');
43
+ // Codex found a THIRD overlap I had missed: /replicate Phase 3 enhances README.md and /start
44
+ // Phase 4 generates-or-updates it, with no preservation rule on either side.
45
+ assert.match(block, /README\.md/, 'README.md overlaps too and must be under the same rule');
46
+ assert.match(block, /if not exists/,
47
+ 'the guard phrase must be present — and it is /replicate\'s own phrase, so the two commands '
48
+ + 'read as one rule rather than two dialects');
49
+ });
50
+
51
+ test('P2 — the PRESENT case has its own stated action, not just an existence check', () => {
52
+ // Checking existence and then overwriting anyway would satisfy a weaker assertion. The property
53
+ // is what happens when the file IS there.
54
+ const block = startPhase1(read('start.md'));
55
+ assert.match(block, /файла НЕТ/, 'the absent case must be stated (a fresh tree still works)');
56
+ assert.match(block, /файл ЕСТЬ/, 'the present case must be stated');
57
+ assert.match(block, /не перегенерировать/,
58
+ 'the present case must forbid regeneration outright — "take it into account" permits it');
59
+ // Cross-family QE (Codex gpt-5.6-sol): "keep it, change only for a documented reason" still let
60
+ // a run rewrite the whole file while calling it "added the missing port". The rule now demands a
61
+ // MINIMAL targeted edit, preservation of everything unrelated, and a look at the diff — the diff
62
+ // is what distinguishes the two, and a promise is not.
63
+ assert.match(block, /МИНИМАЛЬНУЮ/, 'the permitted edit must be bounded, not merely justified');
64
+ assert.match(block, /ПОСМОТРЕТЬ ДИФ/,
65
+ 'inspecting the diff is what turns "I only added a port" from a claim into a check');
66
+ });
67
+
68
+ test('P3 — a permitted change must be NAMED, because an unannounced diff is the same loss', () => {
69
+ const block = startPhase1(read('start.md'));
70
+ assert.match(block, /НАЗВАТЬ каждый изменённый фрагмент/,
71
+ 'a legitimate edit is allowed, but every changed hunk has to be visible to the reader');
72
+ });
73
+
74
+ test('P4 — replicate.md still guards .gitignore with the same phrase, and points at /start', () => {
75
+ const src = read('replicate.md');
76
+ assert.match(src, /`\.gitignore` — if not exists/,
77
+ 'the pre-existing guard must survive — this feature adds one, it does not trade one for another');
78
+ // Codex: the collision is SYMMETRIC. /replicate re-run over a tree that already has a compose
79
+ // would discard it just as /start did; guarding one side only fixes half the defect.
80
+ assert.match(src, /`docker-compose\.yml` — from Architecture\.md services, \*\*if not exists\*\*/,
81
+ 'replicate must guard its own compose too — the guard is symmetric or it is partial');
82
+ assert.match(src, /Phase 1/,
83
+ 'replicate.md must point at the phase that reads these files, so the agreement is written down');
84
+ });
85
+
86
+ test('P5 — the guard is imperative and file-naming, not advisory', () => {
87
+ const block = startPhase1(read('start.md'));
88
+ // "be careful not to overwrite" would pass a reviewer and instruct nothing. The rule must name
89
+ // its files and its actions.
90
+ assert.ok(!/будьте осторожн|постарайтесь не|по возможности/i.test(block),
91
+ 'advisory wording is not a rule');
92
+ assert.match(block, /Правило|правило/,
93
+ 'the block must present itself as a rule');
94
+ });
95
+ });
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ // One file, two canonical schemas. MEASURED: commands/next.md:84-96 documents feature-roadmap.json
4
+ // with a `priority` field over the closed set mvp|high|medium|low, while
5
+ // skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md names the SAME
6
+ // path ("Generate as .claude/feature-roadmap.json") and documents a shape with NO priority at all —
7
+ // and calls itself the single source of truth while being one of two. A generator following the
8
+ // second produces a roadmap the package's own status line cannot read.
9
+ //
10
+ // The consumer was silent about it: statusline.cjs filters `f.priority === 'mvp'`, which is correct
11
+ // against the schema, so a roadmap using `critical` — a value in NO documented enum — or carrying MVP
12
+ // in `tags` rendered "mvp 0/0" and said nothing. The number was right and the reader learned nothing,
13
+ // which is how a divergence becomes permanent.
14
+ //
15
+ // NOT DONE, deliberately: `tags.includes('mvp')` as a "fix". It would bless a document that also used
16
+ // a value in no enum, and teach readers to accept whatever a generator emits. The counter matches the
17
+ // specification; the documents diverged.
18
+ //
19
+ // Also REFUTED while measuring: the report's second defect, a supposed contradiction about who
20
+ // creates the file. feature.md:44 sits under "### What does NOT happen in Mode 2" — it is scoped to
21
+ // the mode where /replicate never runs. Both statements are true in their own mode; what was missing
22
+ // is one place saying both, which P3 now requires.
23
+
24
+ const { test, describe } = require('node:test');
25
+ const assert = require('node:assert/strict');
26
+ const { execFileSync } = require('node:child_process');
27
+ const fs = require('node:fs');
28
+ const os = require('node:os');
29
+ const path = require('node:path');
30
+
31
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
32
+ const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
33
+
34
+ const NEXT = 'commands/next.md';
35
+ const SUGGESTIONS = 'skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md';
36
+
37
+ const STRIP = new RegExp('\\x1b\\[[0-9;]*m', 'g');
38
+
39
+ /** Run the real status line over a real roadmap and return its plain-text Roadmap line. */
40
+ function roadmapLine(features) {
41
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-schema-')));
42
+ try {
43
+ fs.mkdirSync(path.join(root, '.claude', 'hooks'), { recursive: true });
44
+ fs.copyFileSync(path.join(TPL, 'hooks', 'statusline.cjs'),
45
+ path.join(root, '.claude', 'hooks', 'statusline.cjs'));
46
+ fs.writeFileSync(path.join(root, '.claude', 'feature-roadmap.json'),
47
+ JSON.stringify({ version: '1.0', features }));
48
+ let code = 0;
49
+ let out = '';
50
+ try {
51
+ out = execFileSync(process.execPath, [path.join(root, '.claude', 'hooks', 'statusline.cjs')],
52
+ { cwd: root, env: Object.assign({}, process.env, { CLAUDE_PROJECT_DIR: root }),
53
+ encoding: 'utf8', stdio: 'pipe' }) || '';
54
+ } catch (err) {
55
+ code = err.status == null ? 1 : err.status;
56
+ out = err.stdout ? err.stdout.toString() : '';
57
+ }
58
+ const text = out.replace(STRIP, '');
59
+ return { code, line: text.split('\n').find((l) => l.includes('Roadmap')) || '' };
60
+ } finally { fs.rmSync(root, { recursive: true, force: true }); }
61
+ }
62
+
63
+ describe('feature-roadmap.json has ONE schema, and the reader says when a file misses it', () => {
64
+ test('P1 — next.md declares itself canonical and closes the priority set', () => {
65
+ const src = read(NEXT);
66
+ assert.match(src, /\*\*This table is THE schema for `\.claude\/feature-roadmap\.json`\.\*\*/,
67
+ 'the schema must claim the role explicitly, or a second document can claim it too');
68
+ assert.match(src, /any other\s+document that shows the file's fields must point here/,
69
+ 'and say what other documents must do instead of restating it');
70
+ assert.match(src, /`priority` is a CLOSED set — `mvp`, `high`, `medium`, `low`/,
71
+ 'the enum must be stated as closed, in the canonical place');
72
+ });
73
+
74
+ test('P2 — feature-suggestions.md points at the schema instead of restating one', () => {
75
+ const src = read(SUGGESTIONS);
76
+ assert.match(src, /follow `\.claude\/commands\/next\.md`, which holds the single schema/,
77
+ 'the second document must defer, not describe');
78
+ // Cross-family QE: the first version was `!A || B`, which the new "single data file" phrase
79
+ // makes true whether or not the stale claim survives. The claim's ABSENCE is the property —
80
+ // but SCOPED: a later CLAUDE.md snippet in this same file calls the roadmap the source of truth
81
+ // for feature STATUS, which is both true and unrelated. Asserting over the whole file would have
82
+ // forbidden a correct sentence. The claim that mattered was the architecture line at the top.
83
+ const header = src.slice(0, src.indexOf('## 2.'));
84
+ assert.ok(!/single source of truth/.test(header),
85
+ 'the schema-level claim must be gone from the architecture line, not merely accompanied');
86
+ assert.match(header, /single data file/, 'replaced by what it actually is');
87
+ // Its own JSON sketch must not teach the incompatible shape either: an example is what a
88
+ // generator copies, and an example without `priority` is a second schema in disguise.
89
+ const sketch = src.slice(src.indexOf('"features": ['), src.indexOf('```', src.indexOf('"features": [')));
90
+ assert.match(sketch, /"priority": "mvp\|high\|medium\|low"/,
91
+ 'the example must carry the canonical priority field: ' + sketch.slice(0, 200));
92
+ });
93
+
94
+ test('P3 — one place answers who creates the file, for BOTH modes', () => {
95
+ // The report called this a contradiction. It is not: feature.md's claim is scoped to Mode 2.
96
+ // What was missing is a single answer, so a reader landing on one half does not conclude the
97
+ // other is wrong.
98
+ const src = read(NEXT);
99
+ assert.match(src, /Who creates the file/, 'the question must be answered where the schema lives');
100
+ assert.match(src, /Phase 3 generates it from the PRD MVP scope/, 'the /replicate half');
101
+ assert.match(src, /the user\s+writes it by hand/, 'and the Mode 2 half');
102
+ });
103
+
104
+ test('P4 — a priority outside the enum is MARKED, not silently zero', () => {
105
+ const r = roadmapLine([
106
+ { id: 'a', status: 'next', priority: 'critical' },
107
+ { id: 'b', status: 'done', priority: 'critical' },
108
+ ]);
109
+ assert.equal(r.code, 0, 'the status line must never fail the session');
110
+ assert.match(r.line, /⚠2 schema/,
111
+ 'an unreadable roadmap must say so, and say HOW MUCH: ' + r.line);
112
+ // Cross-family QE: the first version replaced the count with '?', discarding what WAS
113
+ // established. The known count is still knowledge; show both.
114
+ assert.match(r.line, /mvp 0\/0 ⚠2/,
115
+ 'the known count must survive beside the warning: ' + r.line);
116
+ });
117
+
118
+ test('P5 — MVP moved into tags, no priority at all: also MARKED', () => {
119
+ // The exact shape the field report was collected on.
120
+ const r = roadmapLine([
121
+ { id: 'a', status: 'next', tags: ['mvp'] },
122
+ { id: 'b', status: 'next', tags: ['mvp'] },
123
+ ]);
124
+ assert.equal(r.code, 0, 'the status line must never fail the session');
125
+ assert.match(r.line, /mvp 0\/0 ⚠2 schema/,
126
+ 'a roadmap with no priority field at all must be marked, with its count: ' + r.line);
127
+ });
128
+
129
+ test('P8 — PARTIAL population is caught: one valid priority beside a broken sibling', () => {
130
+ // Cross-family QE named this false negative exactly: the first detector only inspected features
131
+ // that already HAD a string priority, so a valid entry standing next to a missing or non-string
132
+ // one passed silently — and a half-migrated roadmap is the most likely real one.
133
+ const r = roadmapLine([
134
+ { id: 'a', status: 'done', priority: 'mvp' },
135
+ { id: 'b', status: 'next' }, // missing entirely
136
+ { id: 'c', status: 'next', priority: 3 }, // present, not a string
137
+ ]);
138
+ assert.equal(r.code, 0);
139
+ assert.match(r.line, /⚠2 schema/,
140
+ 'both broken siblings must be counted, not hidden by the valid one: ' + r.line);
141
+ assert.match(r.line, /mvp 1\/1/,
142
+ 'and what IS known must still be reported: ' + r.line);
143
+ });
144
+
145
+ test('P6 — a valid roadmap renders exactly as before, with no marker', () => {
146
+ // The guard on over-marking: a rule that fires on good input is worse than no rule.
147
+ const r = roadmapLine([
148
+ { id: 'a', status: 'done', priority: 'mvp' },
149
+ { id: 'b', status: 'next', priority: 'mvp' },
150
+ { id: 'c', status: 'next', priority: 'low' },
151
+ ]);
152
+ assert.equal(r.code, 0);
153
+ assert.match(r.line, /mvp 1\/2/, 'the real counts must still render: ' + r.line);
154
+ assert.doesNotMatch(r.line, /schema/, 'and no marker on a conforming file: ' + r.line);
155
+
156
+ // An EMPTY roadmap has nothing to be off-schema about; marking it would be a false alarm in the
157
+ // most common state of a fresh project.
158
+ const empty = roadmapLine([]);
159
+ assert.equal(empty.code, 0);
160
+ assert.doesNotMatch(empty.line, /schema/, 'an empty roadmap must not be marked: ' + empty.line);
161
+ });
162
+
163
+ test('P7 — every case exits 0: the marker informs, it never blocks', () => {
164
+ for (const features of [
165
+ [{ id: 'a', status: 'next', priority: 'critical' }],
166
+ [{ id: 'a', status: 'next', tags: ['mvp'] }],
167
+ [{ id: 'a', status: 'next', priority: 'mvp' }],
168
+ [],
169
+ ]) {
170
+ const r = roadmapLine(features);
171
+ assert.equal(r.code, 0,
172
+ 'the status line is advisory and must stay non-blocking: ' + JSON.stringify(features));
173
+ // Cross-family QE: exit 0 alone is satisfied by a program that prints nothing at all. The
174
+ // line must actually be rendered in every case.
175
+ assert.notEqual(r.line, '',
176
+ 'and must still RENDER a roadmap line: ' + JSON.stringify(features));
177
+ }
178
+ });
179
+ });
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ // sparc-prd-mini — Phase 5 must reconcile with Phase 4's data model before its checkpoint.
4
+ //
5
+ // The skill runs strictly linearly: Phase 4 PSEUDOCODE authors `## Data Structures` — entity types
6
+ // with concrete field types — and only then does Phase 5 ARCHITECTURE choose the storage and
7
+ // technology those structures have to live in. A sweep from Phase 5 to end-of-file found ZERO
8
+ // re-reads of Pseudocode.md, so nothing ever noticed the two documents disagreeing. One ordering
9
+ // fact explained three separately-reported field symptoms: a boolean where the schema took an enum,
10
+ // an algorithm using a column the schema lacks, and a status with three values on one side and five
11
+ // on the other.
12
+ //
13
+ // The fix is a RECONCILIATION step, deliberately NOT a reorder: SPARC is an acronym — Specification,
14
+ // Pseudocode, Architecture, Refinement, Completion — and the skill is named `sparc-prd-mini`.
15
+ // Swapping P and A would leave the file claiming a methodology it no longer follows. P5 below guards
16
+ // that refused decision, so a later edit cannot quietly take it.
17
+ //
18
+ // These are PROMPT modules executed by a model, so the strongest deterministic layer available is
19
+ // the file's own content. Each assertion is DISCRIMINATING: removing what it names turns it red.
20
+
21
+ const { test, describe } = require('node:test');
22
+ const assert = require('node:assert/strict');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const SKILL = path.join(
27
+ __dirname, '..', '..', 'templates', '.claude', 'skills', 'sparc-prd-mini', 'SKILL.md',
28
+ );
29
+
30
+ function read() {
31
+ return fs.readFileSync(SKILL, 'utf-8');
32
+ }
33
+
34
+ /** The text between the Phase 5 heading and the Phase 6 heading — placement is the property. */
35
+ function phase5(src) {
36
+ const start = src.indexOf('### Phase 5: ARCHITECTURE');
37
+ const end = src.indexOf('### Phase 6: REFINEMENT');
38
+ assert.ok(start > 0, 'Phase 5 heading must exist');
39
+ assert.ok(end > start, 'Phase 6 heading must follow Phase 5');
40
+ return src.slice(start, end);
41
+ }
42
+
43
+ describe('sparc-prd-mini — Phase 5 reconciles with Pseudocode (PR-005/PR-010)', () => {
44
+ test('P1 — the reconciliation step lives INSIDE Phase 5, before its checkpoint', () => {
45
+ const block = phase5(read());
46
+ assert.match(block, /СВЕРКА С ПСЕВДОКОДОМ/,
47
+ 'the step must be inside Phase 5 — somewhere in the file is not the same property');
48
+ const step = block.indexOf('СВЕРКА С ПСЕВДОКОДОМ');
49
+ const checkpoint = block.indexOf('CHECKPOINT 5');
50
+ assert.ok(checkpoint > 0, 'Phase 5 must still carry its checkpoint');
51
+ assert.ok(step < checkpoint,
52
+ 'reconciling AFTER the user has already approved the phase reconciles nothing');
53
+ });
54
+
55
+ test('P1b — the step is OUTSIDE the [MANUAL] CP5 block, so AUTO mode cannot skip it', () => {
56
+ // Cross-family QE (Codex gpt-5.6-sol) caught this: the first placement landed the step INSIDE
57
+ // the fenced `**[MANUAL] CP5:**` block, which AUTO mode is entitled to skip — a mandatory
58
+ // reconciliation that only happens in one of two run modes is not mandatory. P1's before/after
59
+ // ordering was true and still missed it, because "before the checkpoint text" and "outside the
60
+ // MANUAL-only block" are two different facts.
61
+ const block = phase5(read());
62
+ const step = block.indexOf('СВЕРКА С ПСЕВДОКОДОМ');
63
+ const manual = block.indexOf('**[MANUAL] CP5:**');
64
+ assert.ok(manual > 0, 'the MANUAL checkpoint marker must still exist');
65
+ assert.ok(step < manual,
66
+ 'the step must precede the [MANUAL] marker — inside it, an AUTO run reconciles nothing');
67
+ });
68
+
69
+ test('P2 — it names BOTH artifacts to re-read, not "check for consistency"', () => {
70
+ const block = phase5(read());
71
+ assert.match(block, /Pseudocode\.md/, 'the step must name the file it re-reads');
72
+ assert.match(block, /## Data Structures/, 'and the data-model section within it');
73
+ // Codex: "an algorithm uses a missing column" cannot be detected from the type list alone.
74
+ assert.match(block, /## Core Algorithms/,
75
+ 'the missing-column kind is undetectable without the algorithms — naming only the data '
76
+ + 'structures would promise a check the step cannot perform');
77
+ });
78
+
79
+ test('P3 — all three measured discrepancy kinds are named verbatim', () => {
80
+ const block = phase5(read());
81
+ // Each of these is a real symptom that reached code before this step existed. A generic
82
+ // "look for inconsistencies" would satisfy a reviewer and catch none of them.
83
+ // Checked in BOLD form, i.e. where the kinds are DEFINED. A first draft matched the bare
84
+ // phrase and did not discriminate: the same words also appear inside the example table row, so
85
+ // deleting a kind from the definition list left the assertion green. A phrase appearing in an
86
+ // example is not the same fact as a phrase defining a kind — the fifth instance of that trap
87
+ // in one day, so it is closed by construction here.
88
+ for (const kind of ['смена типа', 'отсутствующая колонка', 'несовпадение набора значений']) {
89
+ assert.ok(block.includes(`**${kind}**`),
90
+ `discrepancy kind must be DEFINED (bold) in the step, not merely mentioned: ${kind}`);
91
+ }
92
+ });
93
+
94
+ test('P4 — a written outcome is required in EVERY case, including no-change', () => {
95
+ const block = phase5(read());
96
+ assert.match(block, /## Reconciliation with Pseudocode/,
97
+ 'the step must name the block it writes into Architecture.md');
98
+ assert.match(block, /Расхождений с/,
99
+ 'the no-change case needs its own stated sentence — silence cannot distinguish '
100
+ + '"reconciled and clean" from "never reconciled"');
101
+ });
102
+
103
+ test('P5 — SPARC order is unchanged: the refused decision stays refused', () => {
104
+ const src = read();
105
+ const order = ['### Phase 3: SPECIFICATION', '### Phase 4: PSEUDOCODE',
106
+ '### Phase 5: ARCHITECTURE', '### Phase 6: REFINEMENT', '### Phase 7: COMPLETION'];
107
+ const at = order.map((h) => {
108
+ const i = src.indexOf(h);
109
+ assert.ok(i > 0, `heading must exist: ${h}`);
110
+ return i;
111
+ });
112
+ for (let i = 1; i < at.length; i += 1) {
113
+ assert.ok(at[i] > at[i - 1],
114
+ `phases must stay in S-P-A-R-C order — the acronym IS the method's name; ${order[i]} moved`);
115
+ }
116
+ });
117
+ });