@dzhechkov/p-replicator 1.5.12 → 1.5.14

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.
@@ -0,0 +1,973 @@
1
+ 'use strict';
2
+
3
+ const { test, describe } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execFileSync } = require('node:child_process');
6
+ const fs = require('node:fs');
7
+ const os = require('node:os');
8
+ const path = require('node:path');
9
+
10
+ const PKG_DIR = path.resolve(__dirname, '..', '..');
11
+ const CLI = path.join(PKG_DIR, 'bin', 'cli.js');
12
+ const MANIFEST = '.p-replicator.json';
13
+
14
+ function tmpDir() {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-e2e-'));
16
+ }
17
+
18
+ function rmRf(dir) {
19
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
20
+ }
21
+
22
+ function runCli(args, cwd) {
23
+ const argv = Array.isArray(args) ? args : args.split(' ').filter(Boolean);
24
+ try {
25
+ const stdout = execFileSync(process.execPath, [CLI, ...argv], {
26
+ cwd,
27
+ encoding: 'utf8',
28
+ stdio: 'pipe',
29
+ });
30
+ return { exitCode: 0, stdout: stdout || '', stderr: '' };
31
+ } catch (err) {
32
+ return {
33
+ exitCode: err.status ?? 1,
34
+ stdout: err.stdout?.toString() ?? '',
35
+ stderr: err.stderr?.toString() ?? '',
36
+ };
37
+ }
38
+ }
39
+
40
+ function exists(cwd, rel) {
41
+ return fs.existsSync(path.join(cwd, rel));
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // --version / --help
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe('e2e: meta flags', () => {
49
+ test('--version prints semver and exits 0', () => {
50
+ const dir = tmpDir();
51
+ try {
52
+ const r = runCli(['--version'], dir);
53
+ assert.equal(r.exitCode, 0);
54
+ assert.match(r.stdout.trim(), /^\d+\.\d+\.\d+/);
55
+ } finally { rmRf(dir); }
56
+ });
57
+
58
+ test('--help shows usage with all commands', () => {
59
+ const dir = tmpDir();
60
+ try {
61
+ const r = runCli(['--help'], dir);
62
+ assert.equal(r.exitCode, 0);
63
+ assert.match(r.stdout, /Usage:/i);
64
+ assert.match(r.stdout, /init/);
65
+ assert.match(r.stdout, /update/);
66
+ assert.match(r.stdout, /remove/);
67
+ assert.match(r.stdout, /list/);
68
+ assert.match(r.stdout, /doctor/);
69
+ } finally { rmRf(dir); }
70
+ });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // init
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe('e2e: init', () => {
78
+ test('init creates manifest and copies components', () => {
79
+ const dir = tmpDir();
80
+ try {
81
+ const r = runCli(['init'], dir);
82
+ assert.equal(r.exitCode, 0,
83
+ `init failed.\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
84
+ assert.ok(exists(dir, MANIFEST), 'manifest not created');
85
+
86
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST), 'utf8'));
87
+ assert.match(manifest.version, /^\d+\.\d+\.\d+/);
88
+ assert.deepEqual(
89
+ manifest.components.sort(),
90
+ ['agents', 'commands', 'hooks', 'rules', 'settings', 'skills'],
91
+ 'v1.4.1 manifest includes 6 pre-shipped components (added: hooks)'
92
+ );
93
+ assert.ok(manifest.files.length > 0, 'no files tracked in manifest');
94
+
95
+ assert.ok(exists(dir, '.claude'), '.claude/ not created');
96
+ assert.ok(exists(dir, '.claude/skills'), '.claude/skills/ not created');
97
+ assert.ok(exists(dir, '.claude/commands/replicate.md'), 'replicate.md not installed');
98
+ assert.ok(exists(dir, '.claude/commands/harvest.md'), 'harvest.md not installed');
99
+ } finally { rmRf(dir); }
100
+ });
101
+
102
+ test('init refuses without --force when manifest exists', () => {
103
+ const dir = tmpDir();
104
+ try {
105
+ runCli(['init'], dir);
106
+ const r = runCli(['init'], dir);
107
+ assert.equal(r.exitCode, 1);
108
+ assert.match(r.stdout + r.stderr, /already installed/i);
109
+ } finally { rmRf(dir); }
110
+ });
111
+
112
+ test('init --force overwrites existing install', () => {
113
+ const dir = tmpDir();
114
+ try {
115
+ runCli(['init'], dir);
116
+ const r = runCli(['init', '--force'], dir);
117
+ assert.equal(r.exitCode, 0);
118
+ assert.ok(exists(dir, MANIFEST));
119
+ } finally { rmRf(dir); }
120
+ });
121
+
122
+ test('init --dry-run leaves filesystem unchanged', () => {
123
+ const dir = tmpDir();
124
+ try {
125
+ const r = runCli(['init', '--dry-run'], dir);
126
+ assert.equal(r.exitCode, 0);
127
+ assert.equal(exists(dir, MANIFEST), false, 'manifest should NOT be created on --dry-run');
128
+ assert.equal(exists(dir, '.claude'), false, '.claude/ should NOT be created on --dry-run');
129
+ assert.match(r.stdout, /Dry run/i);
130
+ } finally { rmRf(dir); }
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // list
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe('e2e: list', () => {
139
+ test('list shows all four component groups after init', () => {
140
+ const dir = tmpDir();
141
+ try {
142
+ runCli(['init'], dir);
143
+ const r = runCli(['list'], dir);
144
+ assert.equal(r.exitCode, 0);
145
+ assert.match(r.stdout, /Skills:/i);
146
+ assert.match(r.stdout, /Commands:/i);
147
+ assert.match(r.stdout, /Agents:/i);
148
+ assert.match(r.stdout, /Rules:/i);
149
+ assert.match(r.stdout, /\/replicate/);
150
+ assert.match(r.stdout, /\/harvest/);
151
+ } finally { rmRf(dir); }
152
+ });
153
+
154
+ test('list refuses when not installed', () => {
155
+ const dir = tmpDir();
156
+ try {
157
+ const r = runCli(['list'], dir);
158
+ assert.equal(r.exitCode, 1);
159
+ assert.match(r.stdout + r.stderr, /not installed/i);
160
+ } finally { rmRf(dir); }
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // doctor
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('e2e: doctor', () => {
169
+ test('doctor passes after fresh init', () => {
170
+ const dir = tmpDir();
171
+ try {
172
+ runCli(['init'], dir);
173
+ const r = runCli(['doctor'], dir);
174
+ assert.equal(r.exitCode, 0,
175
+ `doctor failed after init.\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
176
+ assert.match(r.stdout, /All checks passed/i);
177
+ } finally { rmRf(dir); }
178
+ });
179
+
180
+ test('doctor exits non-zero when not installed', () => {
181
+ const dir = tmpDir();
182
+ try {
183
+ const r = runCli(['doctor'], dir);
184
+ assert.notEqual(r.exitCode, 0);
185
+ } finally { rmRf(dir); }
186
+ });
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // update
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('e2e: update', () => {
194
+ test('update --dry-run reports up-to-date right after init', () => {
195
+ const dir = tmpDir();
196
+ try {
197
+ runCli(['init'], dir);
198
+ const r = runCli(['update', '--dry-run'], dir);
199
+ assert.equal(r.exitCode, 0);
200
+ // Either "Already up to date" or "0 new files\n0 modified files"
201
+ assert.match(
202
+ r.stdout,
203
+ /up to date|0 new files[\s\S]*0 modified files/i
204
+ );
205
+ } finally { rmRf(dir); }
206
+ });
207
+
208
+ test('update refuses when not installed', () => {
209
+ const dir = tmpDir();
210
+ try {
211
+ const r = runCli(['update'], dir);
212
+ assert.equal(r.exitCode, 1);
213
+ assert.match(r.stdout + r.stderr, /not installed/i);
214
+ } finally { rmRf(dir); }
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // remove
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('e2e: remove', () => {
223
+ test('remove deletes manifest and tracked files', () => {
224
+ const dir = tmpDir();
225
+ try {
226
+ runCli(['init'], dir);
227
+ assert.ok(exists(dir, MANIFEST), 'precondition: manifest must exist');
228
+ assert.ok(exists(dir, '.claude/commands/replicate.md'), 'precondition: replicate.md must exist');
229
+
230
+ const r = runCli(['remove'], dir);
231
+ assert.equal(r.exitCode, 0);
232
+
233
+ assert.equal(exists(dir, MANIFEST), false, 'manifest should be removed');
234
+ assert.equal(
235
+ exists(dir, '.claude/commands/replicate.md'),
236
+ false,
237
+ 'tracked files should be removed'
238
+ );
239
+ } finally { rmRf(dir); }
240
+ });
241
+
242
+ test('remove --dry-run keeps files intact', () => {
243
+ const dir = tmpDir();
244
+ try {
245
+ runCli(['init'], dir);
246
+ const r = runCli(['remove', '--dry-run'], dir);
247
+ assert.equal(r.exitCode, 0);
248
+ assert.ok(exists(dir, MANIFEST), 'dry-run should not remove manifest');
249
+ assert.match(r.stdout, /Dry run/i);
250
+ } finally { rmRf(dir); }
251
+ });
252
+
253
+ test('remove refuses when not installed', () => {
254
+ const dir = tmpDir();
255
+ try {
256
+ const r = runCli(['remove'], dir);
257
+ assert.equal(r.exitCode, 1);
258
+ assert.match(r.stdout + r.stderr, /not installed/i);
259
+ } finally { rmRf(dir); }
260
+ });
261
+ });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // unknown command
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('e2e: unknown command', () => {
268
+ test('exits 1 and shows help on unknown command', () => {
269
+ const dir = tmpDir();
270
+ try {
271
+ const r = runCli(['nonsense-command'], dir);
272
+ assert.equal(r.exitCode, 1);
273
+ assert.match(r.stdout + r.stderr, /Unknown command/i);
274
+ } finally { rmRf(dir); }
275
+ });
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Regression: --help component counts (SSOT)
280
+ // Bug fixed: cli.js used to say "1 rule" while EXPECTED_RULES had 2 entries.
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe('e2e: --help shows correct component counts', () => {
284
+ test('mentions 10 skills, 11 commands, 4 agents, 5 rules', () => {
285
+ const dir = tmpDir();
286
+ try {
287
+ const r = runCli(['--help'], dir);
288
+ assert.equal(r.exitCode, 0);
289
+ assert.match(r.stdout, /10\s+skills/i, 'should mention 10 skills');
290
+ assert.match(r.stdout, /11\s+commands/i, 'should mention 11 commands (post v1.4)');
291
+ assert.match(r.stdout, /4\s+agents/i, 'should mention 4 agents');
292
+ assert.match(r.stdout, /5\s+rules/i,
293
+ 'should say "5 rules" (post v1.4: + 3 generic rules)');
294
+ } finally { rmRf(dir); }
295
+ });
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // v1.4: Pre-shipped post-/replicate workflow artifacts
300
+ // init MUST install all 9 generic commands + 3 generic rules + settings.json
301
+ // so /replicate Phase 3 can ENHANCE rather than CREATE them.
302
+ // ---------------------------------------------------------------------------
303
+
304
+ describe('e2e: v1.4 pre-shipped generic toolkit', () => {
305
+ test('init installs all 11 generic commands', () => {
306
+ const dir = tmpDir();
307
+ try {
308
+ runCli(['init'], dir);
309
+ const expected = [
310
+ 'replicate', 'harvest',
311
+ 'start', 'plan', 'feature', 'go', 'run', 'next',
312
+ 'docs', 'deploy', 'myinsights',
313
+ ];
314
+ for (const cmd of expected) {
315
+ assert.ok(
316
+ exists(dir, `.claude/commands/${cmd}.md`),
317
+ `${cmd}.md should be installed by init (v1.4 pre-shipped)`
318
+ );
319
+ }
320
+ } finally { rmRf(dir); }
321
+ });
322
+
323
+ test('init installs all 5 generic rules', () => {
324
+ const dir = tmpDir();
325
+ try {
326
+ runCli(['init'], dir);
327
+ const expected = [
328
+ 'replicate-pipeline',
329
+ 'skill-interface-protocol',
330
+ 'git-workflow',
331
+ 'insights-capture',
332
+ 'feature-lifecycle',
333
+ ];
334
+ for (const rule of expected) {
335
+ assert.ok(
336
+ exists(dir, `.claude/rules/${rule}.md`),
337
+ `${rule}.md should be installed by init (v1.4 pre-shipped)`
338
+ );
339
+ }
340
+ } finally { rmRf(dir); }
341
+ });
342
+
343
+ test('init installs settings.json with hooks', () => {
344
+ const dir = tmpDir();
345
+ try {
346
+ runCli(['init'], dir);
347
+ const settingsPath = path.join(dir, '.claude/settings.json');
348
+ assert.ok(fs.existsSync(settingsPath), 'settings.json should exist');
349
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
350
+ assert.ok(settings.hooks, 'settings.json should have hooks key');
351
+ } finally { rmRf(dir); }
352
+ });
353
+
354
+ test('doctor passes with v1.4 expanded contract', () => {
355
+ const dir = tmpDir();
356
+ try {
357
+ runCli(['init'], dir);
358
+ const r = runCli(['doctor'], dir);
359
+ assert.equal(r.exitCode, 0,
360
+ `doctor failed with v1.4 contract.\nstdout:\n${r.stdout}`);
361
+ assert.match(r.stdout, /All checks passed/i);
362
+ } finally { rmRf(dir); }
363
+ });
364
+ });
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // v1.4: New `verify` command — post-/replicate state check
368
+ // Captures the user's manual verification prompt as a re-runnable check.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ describe('e2e: verify command', () => {
372
+ test('verify reports pre-shipped artifacts after init', () => {
373
+ const dir = tmpDir();
374
+ try {
375
+ runCli(['init'], dir);
376
+ const r = runCli(['verify'], dir);
377
+ assert.equal(r.exitCode, 0,
378
+ `verify failed.\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
379
+ assert.match(r.stdout, /Pre-shipped/i);
380
+ assert.match(r.stdout, /\/run/);
381
+ assert.match(r.stdout, /\/next/);
382
+ } finally { rmRf(dir); }
383
+ });
384
+
385
+ test('verify exits non-zero when not installed', () => {
386
+ const dir = tmpDir();
387
+ try {
388
+ const r = runCli(['verify'], dir);
389
+ assert.notEqual(r.exitCode, 0,
390
+ 'verify on a clean dir should exit non-zero (not installed)');
391
+ } finally { rmRf(dir); }
392
+ });
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // v1.4.1: cross-platform hooks (Node scripts vs bash)
397
+ // ---------------------------------------------------------------------------
398
+
399
+ describe('e2e: v1.4.1 cross-platform hooks', () => {
400
+ test('init installs 4 hook scripts in .claude/hooks/', () => {
401
+ const dir = tmpDir();
402
+ try {
403
+ runCli(['init'], dir);
404
+ const expected = [
405
+ 'session-insights',
406
+ 'autocommit-roadmap',
407
+ 'autocommit-insights',
408
+ 'autocommit-plans',
409
+ ];
410
+ for (const hook of expected) {
411
+ assert.ok(
412
+ exists(dir, `.claude/hooks/${hook}.cjs`),
413
+ `${hook}.cjs should be installed (cross-platform Node script, v1.4.1)`
414
+ );
415
+ }
416
+ } finally { rmRf(dir); }
417
+ });
418
+
419
+ test('settings.json references Node scripts, not bash chains', () => {
420
+ const dir = tmpDir();
421
+ try {
422
+ runCli(['init'], dir);
423
+ const settings = JSON.parse(
424
+ fs.readFileSync(path.join(dir, '.claude/settings.json'), 'utf8')
425
+ );
426
+ // Collect all hook commands
427
+ const allCmds = [];
428
+ for (const event of Object.values(settings.hooks || {})) {
429
+ for (const matcher of event) {
430
+ for (const h of matcher.hooks || []) {
431
+ if (h.command) allCmds.push(h.command);
432
+ }
433
+ }
434
+ }
435
+ assert.ok(allCmds.length > 0, 'no hook commands found');
436
+ for (const cmd of allCmds) {
437
+ // No bash-specific redirect operators
438
+ assert.doesNotMatch(cmd, /2>\/dev\/null/,
439
+ `bash-specific 2>/dev/null in: ${cmd}`);
440
+ assert.doesNotMatch(cmd, /\|\|\s*true/,
441
+ `bash-specific || true in: ${cmd}`);
442
+ // Each command should invoke node + a script
443
+ assert.match(cmd, /node\s+\.claude[/\\]hooks/,
444
+ `expected 'node .claude/hooks/<script>.cjs', got: ${cmd}`);
445
+ }
446
+ } finally { rmRf(dir); }
447
+ });
448
+
449
+ test('hook scripts are syntactically valid Node modules', () => {
450
+ const dir = tmpDir();
451
+ try {
452
+ runCli(['init'], dir);
453
+ const hooks = ['session-insights', 'autocommit-roadmap', 'autocommit-insights', 'autocommit-plans'];
454
+ for (const hook of hooks) {
455
+ const hookPath = path.join(dir, `.claude/hooks/${hook}.cjs`);
456
+ const r = (() => {
457
+ try {
458
+ require('child_process').execFileSync(
459
+ process.execPath, ['--check', hookPath], { stdio: 'pipe' }
460
+ );
461
+ return { ok: true };
462
+ } catch (err) {
463
+ return { ok: false, msg: err.stderr?.toString() ?? err.message };
464
+ }
465
+ })();
466
+ assert.ok(r.ok, `${hook}.cjs has syntax error: ${r.msg}`);
467
+ }
468
+ } finally { rmRf(dir); }
469
+ });
470
+ });
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // v1.4.1: meta-tests for replicate.md ↔ replicate-pipeline.md consistency
474
+ // ---------------------------------------------------------------------------
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // v1.4.2: settings.json merge preserves user customizations
478
+ // ---------------------------------------------------------------------------
479
+
480
+ describe('e2e: v1.4.2 settings.json merge on --force', () => {
481
+ const USER_HOOK_COMMAND = 'echo "USER-CUSTOM-HOOK-MARKER-12345"';
482
+
483
+ test('init --force preserves user-added hooks in settings.json', () => {
484
+ const dir = tmpDir();
485
+ try {
486
+ runCli(['init'], dir);
487
+ const settingsPath = path.join(dir, '.claude/settings.json');
488
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
489
+ // Add a user hook to existing Stop event
490
+ settings.hooks.Stop[0].hooks.push({
491
+ type: 'command',
492
+ command: USER_HOOK_COMMAND,
493
+ timeout: 5,
494
+ });
495
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
496
+
497
+ const r = runCli(['init', '--force'], dir);
498
+ assert.equal(r.exitCode, 0);
499
+
500
+ const merged = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
501
+ const allCmds = merged.hooks.Stop.flatMap((m) => m.hooks).map((h) => h.command);
502
+ assert.ok(allCmds.includes(USER_HOOK_COMMAND),
503
+ 'user-added hook should survive init --force');
504
+ // Template hooks also present
505
+ assert.ok(allCmds.some((c) => c.includes('autocommit-roadmap')),
506
+ 'template hooks also present');
507
+ } finally { rmRf(dir); }
508
+ });
509
+
510
+ test('init --force --reset-settings overwrites user settings', () => {
511
+ const dir = tmpDir();
512
+ try {
513
+ runCli(['init'], dir);
514
+ const settingsPath = path.join(dir, '.claude/settings.json');
515
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
516
+ settings.hooks.Stop[0].hooks.push({
517
+ type: 'command',
518
+ command: USER_HOOK_COMMAND,
519
+ timeout: 5,
520
+ });
521
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
522
+
523
+ const r = runCli(['init', '--force', '--reset-settings'], dir);
524
+ assert.equal(r.exitCode, 0);
525
+
526
+ const reset = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
527
+ const allCmds = reset.hooks.Stop.flatMap((m) => m.hooks).map((h) => h.command);
528
+ assert.ok(!allCmds.includes(USER_HOOK_COMMAND),
529
+ 'user hook should be removed by --reset-settings');
530
+ } finally { rmRf(dir); }
531
+ });
532
+
533
+ test('user-added new event type (PreToolUse) is preserved', () => {
534
+ const dir = tmpDir();
535
+ try {
536
+ runCli(['init'], dir);
537
+ const settingsPath = path.join(dir, '.claude/settings.json');
538
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
539
+ settings.hooks.PreToolUse = [{
540
+ matcher: 'Bash',
541
+ hooks: [{ type: 'command', command: 'audit-bash', timeout: 5 }],
542
+ }];
543
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
544
+
545
+ runCli(['init', '--force'], dir);
546
+
547
+ const merged = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
548
+ assert.ok(merged.hooks.PreToolUse,
549
+ 'user-added PreToolUse event type should survive --force');
550
+ assert.equal(merged.hooks.PreToolUse[0].hooks[0].command, 'audit-bash');
551
+ } finally { rmRf(dir); }
552
+ });
553
+ });
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // v1.4.3: shippedDefaults baseline + orphan hook detection on upgrade
557
+ // ---------------------------------------------------------------------------
558
+
559
+ describe('e2e: v1.4.3 manifest tracks shippedDefaults baseline', () => {
560
+ test('init populates manifest.shippedDefaults["settings.json"]', () => {
561
+ const dir = tmpDir();
562
+ try {
563
+ runCli(['init'], dir);
564
+ const manifest = JSON.parse(
565
+ fs.readFileSync(path.join(dir, '.p-replicator.json'), 'utf8')
566
+ );
567
+ assert.ok(manifest.shippedDefaults,
568
+ 'manifest should have shippedDefaults (v1.4.3+)');
569
+ assert.ok(manifest.shippedDefaults['settings.json'],
570
+ 'should track settings.json template content');
571
+ assert.ok(manifest.shippedDefaults['settings.json'].hooks,
572
+ 'should include the hooks structure');
573
+ } finally { rmRf(dir); }
574
+ });
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // v1.5.0: statusline dashboard
579
+ // ---------------------------------------------------------------------------
580
+
581
+ describe('e2e: v1.5.0 statusline dashboard', () => {
582
+ test('init installs statusline.cjs and state-update.cjs', () => {
583
+ const dir = tmpDir();
584
+ try {
585
+ runCli(['init'], dir);
586
+ assert.ok(exists(dir, '.claude/hooks/statusline.cjs'),
587
+ 'statusline.cjs should be installed in v1.5.0');
588
+ assert.ok(exists(dir, '.claude/hooks/state-update.cjs'),
589
+ 'state-update.cjs should be installed in v1.5.0');
590
+ } finally { rmRf(dir); }
591
+ });
592
+
593
+ test('settings.json registers statusLine config', () => {
594
+ const dir = tmpDir();
595
+ try {
596
+ runCli(['init'], dir);
597
+ const settings = JSON.parse(
598
+ fs.readFileSync(path.join(dir, '.claude/settings.json'), 'utf8')
599
+ );
600
+ assert.ok(settings.statusLine, 'statusLine field should be present in settings.json');
601
+ assert.equal(settings.statusLine.type, 'command');
602
+ assert.match(settings.statusLine.command, /node\s+\.claude[/\\]hooks[/\\]statusline\.cjs/,
603
+ 'should invoke statusline.cjs via node');
604
+ } finally { rmRf(dir); }
605
+ });
606
+
607
+ test('statusline.cjs runs cleanly after init (exit 0)', () => {
608
+ const dir = tmpDir();
609
+ try {
610
+ runCli(['init'], dir);
611
+ const result = (() => {
612
+ try {
613
+ const stdout = require('child_process').execFileSync(
614
+ process.execPath,
615
+ [path.join(dir, '.claude/hooks/statusline.cjs')],
616
+ { cwd: dir, encoding: 'utf8', stdio: 'pipe' }
617
+ );
618
+ return { exitCode: 0, stdout };
619
+ } catch (err) {
620
+ return { exitCode: err.status ?? 1, stdout: err.stdout?.toString() ?? '', stderr: err.stderr?.toString() ?? '' };
621
+ }
622
+ })();
623
+ assert.equal(result.exitCode, 0,
624
+ `statusline failed: ${result.stderr || 'no stderr'}`);
625
+ assert.ok(result.stdout.length > 0, 'statusline should produce output');
626
+ } finally { rmRf(dir); }
627
+ });
628
+
629
+ test('statusline output mentions package name and 5 section markers', () => {
630
+ const dir = tmpDir();
631
+ try {
632
+ runCli(['init'], dir);
633
+ const stdout = require('child_process').execFileSync(
634
+ process.execPath,
635
+ [path.join(dir, '.claude/hooks/statusline.cjs')],
636
+ { cwd: dir, encoding: 'utf8', stdio: 'pipe' }
637
+ );
638
+ // Strip ANSI escape codes for content checks
639
+ const plain = stdout.replace(/\x1b\[[0-9;]*m/g, '');
640
+ assert.match(plain, /P-Replicator/i, 'should show package name');
641
+ assert.match(plain, /Pipeline/i, 'Pipeline section');
642
+ assert.match(plain, /Roadmap/i, 'Roadmap section');
643
+ assert.match(plain, /SPARC/i, 'SPARC section');
644
+ assert.match(plain, /Toolkit|Skills/i, 'Toolkit section');
645
+ assert.match(plain, /Insights|Tests|MCP/i, 'Status section');
646
+ } finally { rmRf(dir); }
647
+ });
648
+
649
+ test('statusline shows ADRs and Plans counts', () => {
650
+ const dir = tmpDir();
651
+ try {
652
+ runCli(['init'], dir);
653
+ // Create a fake plan and an ADR doc
654
+ fs.mkdirSync(path.join(dir, 'docs/plans'), { recursive: true });
655
+ fs.writeFileSync(path.join(dir, 'docs/plans/sample.md'), '# Plan\n');
656
+ fs.writeFileSync(path.join(dir, 'docs/ADR.md'),
657
+ '# ADR\n\n## ADR-001\n\n## ADR-002\n');
658
+
659
+ const stdout = require('child_process').execFileSync(
660
+ process.execPath,
661
+ [path.join(dir, '.claude/hooks/statusline.cjs')],
662
+ { cwd: dir, encoding: 'utf8', stdio: 'pipe' }
663
+ );
664
+ const plain = stdout.replace(/\x1b\[[0-9;]*m/g, '');
665
+ assert.match(plain, /Plans[^|]*1/, 'should show 1 plan');
666
+ assert.match(plain, /ADR[^|]*2/, 'should show 2 ADRs');
667
+ } finally { rmRf(dir); }
668
+ });
669
+
670
+ test('statusline shows roadmap progress when feature-roadmap.json exists', () => {
671
+ const dir = tmpDir();
672
+ try {
673
+ runCli(['init'], dir);
674
+ fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
675
+ fs.writeFileSync(path.join(dir, '.claude/feature-roadmap.json'), JSON.stringify({
676
+ version: '1.0',
677
+ features: [
678
+ { id: 'a', priority: 'mvp', status: 'done' },
679
+ { id: 'b', priority: 'mvp', status: 'next' },
680
+ { id: 'c', priority: 'high', status: 'planned' },
681
+ ],
682
+ }));
683
+ const stdout = require('child_process').execFileSync(
684
+ process.execPath,
685
+ [path.join(dir, '.claude/hooks/statusline.cjs')],
686
+ { cwd: dir, encoding: 'utf8', stdio: 'pipe' }
687
+ );
688
+ const plain = stdout.replace(/\x1b\[[0-9;]*m/g, '');
689
+ assert.match(plain, /1.*\/.*3/, 'should show 1/3 done');
690
+ } finally { rmRf(dir); }
691
+ });
692
+
693
+ test('statusline does NOT throw when optional files are missing', () => {
694
+ const dir = tmpDir();
695
+ try {
696
+ runCli(['init'], dir);
697
+ // Don't create any optional files (no docs/, no roadmap, no insights)
698
+ const result = (() => {
699
+ try {
700
+ require('child_process').execFileSync(
701
+ process.execPath,
702
+ [path.join(dir, '.claude/hooks/statusline.cjs')],
703
+ { cwd: dir, encoding: 'utf8', stdio: 'pipe' }
704
+ );
705
+ return true;
706
+ } catch { return false; }
707
+ })();
708
+ assert.ok(result, 'statusline should be defensive against missing files');
709
+ } finally { rmRf(dir); }
710
+ });
711
+ });
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // v1.5.0: --feature-branches flag in /run, /go, /next
715
+ // ---------------------------------------------------------------------------
716
+
717
+ describe('meta: v1.5.0 --feature-branches flag documented', () => {
718
+ const TEMPLATES = path.resolve(__dirname, '..', '..', 'templates');
719
+
720
+ test('/run.md mentions --feature-branches and feature/{NNN}-{id} format', () => {
721
+ const content = fs.readFileSync(
722
+ path.join(TEMPLATES, '.claude/commands/run.md'), 'utf8'
723
+ );
724
+ assert.match(content, /--feature-branches/,
725
+ 'run.md should document --feature-branches');
726
+ assert.match(content, /feature\/\{?NNN\}?-\{?id\}?|feature\/\d{3}-/,
727
+ 'run.md should mention feature/{NNN}-{id} branch format');
728
+ assert.match(content, /--auto-merge/i,
729
+ 'run.md should mention --auto-merge companion flag');
730
+ assert.match(content, /auto-stash|stash/i,
731
+ 'run.md should mention auto-stash for dirty working tree');
732
+ });
733
+
734
+ test('/go.md mentions --feature-branches', () => {
735
+ const content = fs.readFileSync(
736
+ path.join(TEMPLATES, '.claude/commands/go.md'), 'utf8'
737
+ );
738
+ assert.match(content, /--feature-branches/,
739
+ 'go.md should document --feature-branches');
740
+ });
741
+
742
+ test('/next.md mentions number and branch fields in roadmap schema', () => {
743
+ const content = fs.readFileSync(
744
+ path.join(TEMPLATES, '.claude/commands/next.md'), 'utf8'
745
+ );
746
+ assert.match(content, /"number"/, 'next.md should mention "number" field');
747
+ assert.match(content, /"branch"/, 'next.md should mention "branch" field');
748
+ });
749
+ });
750
+
751
+ describe('e2e: v1.4.3 orphan hook detection on init --force', () => {
752
+ test('removes hook that was in old shippedDefaults but no longer in current template', () => {
753
+ const dir = tmpDir();
754
+ try {
755
+ runCli(['init'], dir);
756
+ const settingsPath = path.join(dir, '.claude/settings.json');
757
+ const manifestPath = path.join(dir, '.p-replicator.json');
758
+
759
+ // Step 1: simulate "user has been running v1.x with hook OBSOLETE"
760
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
761
+ settings.hooks.Stop[0].hooks.push({
762
+ type: 'command',
763
+ command: 'OBSOLETE_HOOK_FROM_v1.x',
764
+ timeout: 5,
765
+ });
766
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
767
+
768
+ // Step 2: rewrite manifest's shippedDefaults to claim "old version included OBSOLETE"
769
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
770
+ manifest.shippedDefaults['settings.json'].hooks.Stop[0].hooks.push({
771
+ type: 'command',
772
+ command: 'OBSOLETE_HOOK_FROM_v1.x',
773
+ timeout: 5,
774
+ });
775
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
776
+
777
+ // Step 3: now init --force — current template does NOT have OBSOLETE
778
+ runCli(['init', '--force'], dir);
779
+
780
+ // Step 4: verify OBSOLETE was detected as orphan and removed
781
+ const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
782
+ const allCmds = after.hooks.Stop.flatMap((m) => m.hooks).map((h) => h.command);
783
+ assert.ok(
784
+ !allCmds.includes('OBSOLETE_HOOK_FROM_v1.x'),
785
+ 'orphan (in old shippedDefaults, not in current template) should be removed'
786
+ );
787
+ } finally { rmRf(dir); }
788
+ });
789
+
790
+ test('user-added hook preserved alongside orphan removal', () => {
791
+ const dir = tmpDir();
792
+ try {
793
+ runCli(['init'], dir);
794
+ const settingsPath = path.join(dir, '.claude/settings.json');
795
+ const manifestPath = path.join(dir, '.p-replicator.json');
796
+
797
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
798
+ settings.hooks.Stop[0].hooks.push(
799
+ { type: 'command', command: 'USER_CUSTOM_HOOK', timeout: 5 },
800
+ { type: 'command', command: 'OBSOLETE_v1', timeout: 5 }
801
+ );
802
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
803
+
804
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
805
+ manifest.shippedDefaults['settings.json'].hooks.Stop[0].hooks.push(
806
+ { type: 'command', command: 'OBSOLETE_v1', timeout: 5 }
807
+ );
808
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
809
+
810
+ runCli(['init', '--force'], dir);
811
+
812
+ const after = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
813
+ const allCmds = after.hooks.Stop.flatMap((m) => m.hooks).map((h) => h.command);
814
+ assert.ok(allCmds.includes('USER_CUSTOM_HOOK'),
815
+ 'user-added (never in old shippedDefaults) should survive');
816
+ assert.ok(!allCmds.includes('OBSOLETE_v1'),
817
+ 'orphan (in old shippedDefaults but not in current) should be removed');
818
+ } finally { rmRf(dir); }
819
+ });
820
+ });
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // v1.4.2: doctor reports git on PATH
824
+ // ---------------------------------------------------------------------------
825
+
826
+ describe('e2e: v1.4.2 doctor checks git prerequisite', () => {
827
+ test('doctor mentions git in Prerequisites section', () => {
828
+ const dir = tmpDir();
829
+ try {
830
+ runCli(['init'], dir);
831
+ const r = runCli(['doctor'], dir);
832
+ // Whether pass or fail, doctor should display 'Prerequisites' + 'git'
833
+ assert.match(r.stdout, /Prerequisites/i, 'doctor should have a Prerequisites section');
834
+ assert.match(r.stdout, /git/i, 'doctor should mention git');
835
+ } finally { rmRf(dir); }
836
+ });
837
+ });
838
+
839
+ describe('meta: doc-consistency replicate.md ↔ replicate-pipeline.md', () => {
840
+ const TEMPLATES = path.resolve(__dirname, '..', '..', 'templates');
841
+ const REPLICATE_MD = path.join(TEMPLATES, '.claude/commands/replicate.md');
842
+ const RULE_MD = path.join(TEMPLATES, '.claude/rules/replicate-pipeline.md');
843
+
844
+ test('replicate-pipeline.md mentions every pre-shipped command name', () => {
845
+ const ruleContent = fs.readFileSync(RULE_MD, 'utf8');
846
+ const utils = require('../../src/utils');
847
+ for (const cmd of Object.keys(utils.COMPONENTS.commands.items)) {
848
+ // Match either /cmd (with word boundary) OR `cmd` (backticked)
849
+ const slashRe = new RegExp(`/${cmd}\\b`);
850
+ const tickRe = new RegExp('`' + cmd + '`');
851
+ const matched = slashRe.test(ruleContent) || tickRe.test(ruleContent);
852
+ assert.ok(
853
+ matched,
854
+ `replicate-pipeline.md should mention '${cmd}' as /${cmd} or \`${cmd}\` (drift detected)`
855
+ );
856
+ }
857
+ });
858
+
859
+ test('replicate.md Phase 3 does NOT claim to generate any pre-shipped command (v1.4.2 stronger)', () => {
860
+ const replicateContent = fs.readFileSync(REPLICATE_MD, 'utf8');
861
+ const utils = require('../../src/utils');
862
+ // Capture only the Phase 3 section
863
+ const phase3Match = replicateContent.match(/### Phase 3:[\s\S]*?(?=### Phase 4:|$)/);
864
+ assert.ok(phase3Match, 'Phase 3 section not found');
865
+ const phase3 = phase3Match[0];
866
+
867
+ // Scope to the "Generate these project-specific files" sub-section — that's
868
+ // where drift would manifest. The "do NOT overwrite" sub-section legitimately
869
+ // mentions pre-shipped command names.
870
+ const splitMarker = /Generate these project-specific files/i;
871
+ const generationSection = splitMarker.test(phase3)
872
+ ? phase3.split(splitMarker)[1] || ''
873
+ : '';
874
+
875
+ if (!generationSection) {
876
+ // No explicit "to generate" section — drift unlikely (acceptable)
877
+ return;
878
+ }
879
+
880
+ // Allowlist: commands that Phase 3 LEGITIMATELY may generate (conditional)
881
+ const allowedToGenerate = new Set(['feature-ent']);
882
+
883
+ for (const cmd of Object.keys(utils.COMPONENTS.commands.items)) {
884
+ if (allowedToGenerate.has(cmd)) continue;
885
+
886
+ // Pattern A: explicit verb + filename
887
+ // generate / create / produce / write / make .../<cmd>.md
888
+ const verbRe = new RegExp(
889
+ `(generate|create|produce|write|make|output)\\s+[^\\n]{0,80}?[/\\\\]?${cmd}\\.md`,
890
+ 'i'
891
+ );
892
+ // Pattern B: list-style — `cmd.md` in a bullet (— or -) within generation section
893
+ const listRe = new RegExp(
894
+ `[-*]\\s*\`${cmd}\\.md\``
895
+ );
896
+
897
+ const verbMatched = verbRe.test(generationSection);
898
+ const listMatched = listRe.test(generationSection);
899
+ assert.ok(
900
+ !verbMatched && !listMatched,
901
+ `Phase 3 should NOT list pre-shipped /${cmd} as generated ` +
902
+ `(drift: verb=${verbMatched}, list=${listMatched})`
903
+ );
904
+ }
905
+ });
906
+ });
907
+
908
+ // ---------------------------------------------------------------------------
909
+ // Regression: update.js manifest preservation (data loss bug)
910
+ //
911
+ // Pre-fix bug: update.js called getRelativePaths(projectClaude), which walked
912
+ // the user's full .claude/ tree — capturing project-generated files (e.g.
913
+ // /replicate output: start.md, feature.md, plan.md). These were written to
914
+ // manifest.files; a subsequent `remove` would then delete them, contradicting
915
+ // remove.js's own footer guarantee.
916
+ // ---------------------------------------------------------------------------
917
+
918
+ describe('e2e: update.js manifest preservation', () => {
919
+ // To trigger the bug we need a real diff in update — otherwise update.js exits early
920
+ // with "Already up to date" before reaching the manifest-rewrite path. We force a
921
+ // modified file by overwriting a template-tracked file in the project, simulating a
922
+ // real "template upstream changed since install" scenario.
923
+ function setupWithDiff(dir, generatedFile, generatedContent) {
924
+ runCli(['init'], dir);
925
+ // Force `modified[]` to be non-empty so update reaches the manifest rewrite path.
926
+ fs.writeFileSync(
927
+ path.join(dir, '.claude/commands/replicate.md'),
928
+ '# locally drifted content forcing diff\n'
929
+ );
930
+ // Project-generated file (simulates /replicate output):
931
+ fs.writeFileSync(path.join(dir, generatedFile), generatedContent);
932
+ }
933
+
934
+ test('update does NOT track project-generated files in manifest', () => {
935
+ const dir = tmpDir();
936
+ try {
937
+ // Use a file that is NOT pre-shipped (start.md is now pre-shipped in v1.4).
938
+ // .claude/agents/planner.md is generated by /replicate Phase 3 only.
939
+ const generated = '.claude/agents/planner.md';
940
+ setupWithDiff(dir, generated, '# Generated by /replicate\n');
941
+
942
+ const r = runCli(['update'], dir);
943
+ assert.equal(r.exitCode, 0,
944
+ `update failed.\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
945
+
946
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, '.p-replicator.json'), 'utf8'));
947
+ const hasGenerated = manifest.files.some((f) =>
948
+ f.replace(/\\/g, '/').endsWith('agents/planner.md')
949
+ );
950
+ assert.equal(hasGenerated, false,
951
+ 'project-generated planner.md should NOT be tracked in manifest after update');
952
+ } finally { rmRf(dir); }
953
+ });
954
+
955
+ test('remove after update keeps project-generated files intact', () => {
956
+ const dir = tmpDir();
957
+ try {
958
+ // Use a file that is NOT pre-shipped (start.md is now pre-shipped in v1.4).
959
+ // .claude/agents/planner.md is generated by /replicate Phase 3 only.
960
+ const generated = '.claude/agents/planner.md';
961
+ setupWithDiff(dir, generated, '# Generated by /replicate\n');
962
+
963
+ runCli(['update'], dir);
964
+ const r = runCli(['remove'], dir);
965
+ assert.equal(r.exitCode, 0);
966
+
967
+ assert.ok(
968
+ exists(dir, generated),
969
+ 'project-generated planner.md should NOT be deleted by remove (was never package-tracked)'
970
+ );
971
+ } finally { rmRf(dir); }
972
+ });
973
+ });