@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
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+
3
+ // The six shipped hooks resolved BOTH their own file and their own data against a directory that
4
+ // drifts. One `cd` inside a Bash tool call broke all six for the rest of the session, silently,
5
+ // because hooks are non-blocking.
6
+ //
7
+ // Two independent resolutions, failing differently:
8
+ // - the HOST resolves the command string -> wrong: MODULE_NOT_FOUND, loud;
9
+ // - the SCRIPT resolves its own data -> wrong: exit 0 having done nothing, silent.
10
+ //
11
+ // This suite is deliberately BEHAVIOURAL where it can be. The field report's own verification
12
+ // looped the hooks from a subdirectory and checked the exit code: all four returned 0 while
13
+ // committing nothing and injecting nothing. An exit code proves the module LOADED. P4/P5/P6 assert
14
+ // an EFFECT, and P5 is the one that fails if either half of the fix is missing.
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 PKG_DIR = path.resolve(__dirname, '..', '..');
24
+ const TPL = path.join(PKG_DIR, 'templates', '.claude');
25
+ const HOOKS = ['autocommit-roadmap', 'autocommit-insights', 'autocommit-plans',
26
+ 'session-insights', 'statusline', 'state-update'];
27
+
28
+ const readTpl = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
29
+
30
+ /** Source with comments removed. Cross-family QE: the first version of P3 was satisfied by the
31
+ * explanatory comment that NAMES both CLAUDE_PROJECT_DIR and __dirname, while the executable code
32
+ * could still anchor anywhere. A mention is not a use. */
33
+ function code(src) {
34
+ return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
35
+ }
36
+ const settings = () => JSON.parse(readTpl('settings.json'));
37
+
38
+ /** Every command string the host will spawn: the four hooks plus the status line. */
39
+ function allCommands() {
40
+ const s = settings();
41
+ const out = [];
42
+ if (s.statusLine && s.statusLine.command) out.push(s.statusLine.command);
43
+ for (const entries of Object.values(s.hooks || {})) {
44
+ for (const entry of entries) for (const h of entry.hooks || []) out.push(h.command);
45
+ }
46
+ assert.ok(out.length >= 5, 'expected at least the 4 hooks + statusLine, got ' + out.length);
47
+ return out;
48
+ }
49
+
50
+ /** A throwaway git project with the shipped hooks installed and a subdirectory to run them from. */
51
+ function project() {
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-anchor-'));
53
+ const real = fs.realpathSync(dir); // macOS /var -> /private/var, else git paths differ
54
+ fs.mkdirSync(path.join(real, '.claude', 'hooks'), { recursive: true });
55
+ for (const h of HOOKS) {
56
+ fs.copyFileSync(path.join(TPL, 'hooks', h + '.cjs'),
57
+ path.join(real, '.claude', 'hooks', h + '.cjs'));
58
+ }
59
+ fs.mkdirSync(path.join(real, 'projects', '01-sub'), { recursive: true });
60
+ const git = (args) => execFileSync('git', args, { cwd: real, stdio: 'ignore' });
61
+ git(['init', '-q']);
62
+ git(['config', 'user.email', 'test@example.invalid']);
63
+ git(['config', 'user.name', 'test']);
64
+ return { root: real, sub: path.join(real, 'projects', '01-sub'), git };
65
+ }
66
+
67
+ /** Run one hook FROM THE SUBDIRECTORY — the only place this defect is visible. */
68
+ function runFromSub(p, hook, { withEnv = true, args = [] } = {}) {
69
+ const env = { ...process.env };
70
+ if (withEnv) env.CLAUDE_PROJECT_DIR = p.root;
71
+ else delete env.CLAUDE_PROJECT_DIR;
72
+ try {
73
+ const stdout = execFileSync(process.execPath,
74
+ [path.join(p.root, '.claude', 'hooks', hook + '.cjs'), ...args],
75
+ { cwd: p.sub, env, encoding: 'utf8', stdio: 'pipe' });
76
+ return { code: 0, stdout: stdout || '' };
77
+ } catch (err) {
78
+ return { code: err.status ?? 1, stdout: err.stdout?.toString() ?? '' };
79
+ }
80
+ }
81
+
82
+ describe('hooks resolve against the project, not the drifting cwd (PR-013)', () => {
83
+ test('P1 — no command the host spawns resolves relatively', () => {
84
+ for (const cmd of allCommands()) {
85
+ assert.doesNotMatch(cmd, /node\s+["']?\.claude[/\\]/,
86
+ 'a relative script path resolves against the drifting cwd, not settings.json: ' + cmd);
87
+ assert.match(cmd, /CLAUDE_PROJECT_DIR/,
88
+ 'every command must be anchored at the project root: ' + cmd);
89
+ // A correctly-spelled anchor in front of a script that does not exist is still a dead hook.
90
+ const named = cmd.match(/hooks\/([A-Za-z0-9._-]+\.cjs)/);
91
+ assert.ok(named, 'the command must name a .cjs script: ' + cmd);
92
+ assert.ok(fs.existsSync(path.join(TPL, 'hooks', named[1])),
93
+ 'settings.json points at a script this package does not ship: ' + named[1]);
94
+ }
95
+ });
96
+
97
+ test('P2 — the anchor is the PowerShell-safe braced form, and the rejected spellings stay rejected', () => {
98
+ for (const cmd of allCommands()) {
99
+ // MEASURED from the Claude Code binary: it rewrites the exact token ${CLAUDE_PROJECT_DIR}
100
+ // to ${env:CLAUDE_PROJECT_DIR} for PowerShell, and warns that the UNBRACED $CLAUDE_PROJECT_DIR
101
+ // "PowerShell reads as an undefined variable ($null)".
102
+ assert.match(cmd, /\$\{CLAUDE_PROJECT_DIR\}/,
103
+ 'must use the braced form the host rewrites for PowerShell: ' + cmd);
104
+ // ${CLAUDE_PROJECT_DIR:-.} is what this repo's own config uses and is REJECTED here: the
105
+ // rewrite is a literal replaceAll of "${CLAUDE_PROJECT_DIR}", so the :- form never matches
106
+ // and stays a POSIX-only construct — against the template's own cross-platform promise.
107
+ assert.doesNotMatch(cmd, /\$\{CLAUDE_PROJECT_DIR:-/,
108
+ 'the POSIX default-expansion form is not cross-platform: ' + cmd);
109
+ assert.doesNotMatch(cmd, /\$CLAUDE_PROJECT_DIR[^{]/,
110
+ 'the unbraced form is null on PowerShell: ' + cmd);
111
+ assert.match(cmd, /"\$\{CLAUDE_PROJECT_DIR\}[^"]*"/,
112
+ 'the path must be double-quoted — a project path may contain spaces: ' + cmd);
113
+ }
114
+ });
115
+
116
+ test('P3 — no hook reads process.cwd(); all six are anchored', () => {
117
+ for (const h of HOOKS) {
118
+ const src = code(readTpl(path.join('hooks', h + '.cjs')));
119
+ assert.doesNotMatch(src, /process\.cwd\(\)/,
120
+ h + '.cjs still anchors on the drifting cwd — settings.json alone only makes it RUN');
121
+ assert.match(src, /process\.env\.CLAUDE_PROJECT_DIR/,
122
+ h + '.cjs must consult the host first — it is authoritative about the project');
123
+ assert.match(src, /__dirname/,
124
+ h + '.cjs must fall back to its own location, so it works with the variable absent');
125
+ // A truthy but RELATIVE host value would still resolve against the drifting cwd — the very
126
+ // bug this anchor removes. Truthiness is not enough; absoluteness is the property.
127
+ assert.match(src, /path\.isAbsolute\(/,
128
+ h + '.cjs must require the host value to be ABSOLUTE, not merely non-empty');
129
+ }
130
+ });
131
+
132
+ test('P4 — with CLAUDE_PROJECT_DIR UNSET, a hook still finds the project', () => {
133
+ // This is what makes the settings fix non-load-bearing for correctness: an older host, a
134
+ // hand-run, a CI shell. It also pins the || ordering: remove the __dirname fallback and this
135
+ // is the assertion that goes red.
136
+ const p = project();
137
+ try {
138
+ fs.mkdirSync(path.join(p.root, '.claude', 'insights'), { recursive: true });
139
+ fs.writeFileSync(path.join(p.root, '.claude', 'insights', 'index.md'),
140
+ '## Insight one\n\nbody one\n');
141
+ const r = runFromSub(p, 'session-insights', { withEnv: false });
142
+ assert.equal(r.code, 0, 'the hook must stay non-blocking');
143
+ assert.match(r.stdout, /Insight one/,
144
+ 'with no env var and cwd in a subdirectory, the hook must still read the ROOT index');
145
+ } finally { fs.rmSync(p.root, { recursive: true, force: true }); }
146
+ });
147
+
148
+ test('P5 — EFFECT from a subdirectory: the roadmap is actually committed', () => {
149
+ // The property the whole feature exists for. An exit-code check passes on the broken hook;
150
+ // this does not. Fails if EITHER half of the fix is missing.
151
+ const p = project();
152
+ try {
153
+ fs.writeFileSync(path.join(p.root, '.claude', 'feature-roadmap.json'), '{"v":1}\n');
154
+ p.git(['add', '-A']);
155
+ p.git(['commit', '-q', '-m', 'base']);
156
+ fs.writeFileSync(path.join(p.root, '.claude', 'feature-roadmap.json'), '{"v":2}\n');
157
+
158
+ const before = execFileSync('git', ['rev-list', '--count', 'HEAD'],
159
+ { cwd: p.root, encoding: 'utf8' }).trim();
160
+ const r = runFromSub(p, 'autocommit-roadmap');
161
+ assert.equal(r.code, 0, 'the hook must stay non-blocking');
162
+ const after = execFileSync('git', ['rev-list', '--count', 'HEAD'],
163
+ { cwd: p.root, encoding: 'utf8' }).trim();
164
+
165
+ assert.equal(Number(after), Number(before) + 1,
166
+ 'a changed roadmap must be COMMITTED when the hook runs from a subdirectory — '
167
+ + 'this is the assertion the report\'s exit-code check could not make');
168
+ const clean = execFileSync('git', ['status', '--porcelain', '--', '.claude/feature-roadmap.json'],
169
+ { cwd: p.root, encoding: 'utf8' });
170
+ assert.equal(clean.trim(), '', 'and the file must be left clean, not merely staged');
171
+ } finally { fs.rmSync(p.root, { recursive: true, force: true }); }
172
+ });
173
+
174
+ test('P7 — every autocommit hook puts -m BEFORE the `--`, or it commits nothing', () => {
175
+ // Found BY P5, not by review: everything after `--` is a pathspec, so
176
+ // ['commit','--only','--',RELATIVE,'-m',MSG] made git look for files literally named '-m' and
177
+ // 'docs(roadmap): auto-update'. It failed from EVERY directory, and the hook exits 0 on failure,
178
+ // so three autocommit hooks had never committed anything and nothing said so. P5 proves the
179
+ // behaviour for one of the three; this pins the shape for all three, since the other two have
180
+ // no git fixture of their own.
181
+ for (const h of ['autocommit-roadmap', 'autocommit-insights', 'autocommit-plans']) {
182
+ const src = readTpl(path.join('hooks', h + '.cjs'));
183
+ const m = src.match(/git\(\[('commit'[^\]]*)\]\)/);
184
+ assert.ok(m, h + '.cjs must invoke git commit');
185
+ const args = m[1];
186
+ // Cross-family QE: comparing indexOf directly made a MISSING -m pass, since -1 is less than
187
+ // any real index. Presence first, then order.
188
+ const mi = args.indexOf("'-m'");
189
+ const dd = args.indexOf("'--'");
190
+ assert.ok(mi >= 0, h + ".cjs: git commit must carry -m at all: " + args);
191
+ assert.ok(dd >= 0, h + ".cjs: git commit must carry the `--` pathspec separator: " + args);
192
+ assert.ok(mi < dd,
193
+ h + ".cjs: -m must precede `--`, else the message is parsed as a pathspec: " + args);
194
+ }
195
+ });
196
+
197
+ test('P6 — writer and reader agree on ONE path, from any cwd', () => {
198
+ // state-update.cjs writes the state, statusline.cjs reads it. Two drifting anchors can
199
+ // disagree, and a status line reading a file nobody wrote is worse than none.
200
+ const p = project();
201
+ try {
202
+ // A DISTINCTIVE value: cross-family QE noted that matching /replicate|VALIDATE/ would pass on
203
+ // any static status output that happens to contain those words, without reading the file at
204
+ // all. A token that appears nowhere else can only have come through the state file.
205
+ const TOKEN = 'ZQPHASE7X';
206
+ const w = runFromSub(p, 'state-update',
207
+ { args: ['--command', '/replicate', '--phase', TOKEN, '--index', '2', '--total', '4'] });
208
+ assert.equal(w.code, 0, 'the writer must stay non-blocking');
209
+ const stateFile = path.join(p.root, '.claude', '.p-replicator-state.json');
210
+ assert.ok(fs.existsSync(stateFile),
211
+ 'the writer must publish state at the PROJECT root, not under the subdirectory it ran from');
212
+ assert.doesNotMatch(
213
+ fs.existsSync(path.join(p.sub, '.claude')) ? 'stray' : 'clean', /stray/,
214
+ 'and must not have created a second .claude tree under the subdirectory');
215
+
216
+ const r = runFromSub(p, 'statusline');
217
+ assert.equal(r.code, 0, 'the reader must stay non-blocking');
218
+ assert.match(r.stdout, new RegExp(TOKEN),
219
+ 'the reader must echo the DISTINCTIVE token the writer just published — a token that '
220
+ + 'appears nowhere else can only have arrived through the state file');
221
+ } finally { fs.rmSync(p.root, { recursive: true, force: true }); }
222
+ });
223
+ });
@@ -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
+ });