@dzhechkov/p-replicator 1.5.18 → 1.9.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 (89) hide show
  1. package/.dz-manifest.json +173 -57
  2. package/CHANGELOG.md +262 -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 +139 -16
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +346 -56
  19. package/src/utils.js +4 -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 +183 -9
  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-docs-complete.cjs +174 -0
  29. package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
  30. package/templates/.claude/hooks/check-ports.cjs +232 -0
  31. package/templates/.claude/hooks/session-insights.cjs +13 -1
  32. package/templates/.claude/hooks/state-update.cjs +13 -1
  33. package/templates/.claude/hooks/statusline.cjs +145 -18
  34. package/templates/.claude/rules/docker-ports.md +123 -0
  35. package/templates/.claude/rules/replicate-pipeline.md +19 -6
  36. package/templates/.claude/rules/skill-interface-protocol.md +9 -0
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +6 -4
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  44. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  45. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  46. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  47. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +2 -2
  48. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  49. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  50. package/templates/.claude/skills/requirements-validator/SKILL.md +59 -0
  51. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  52. package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
  53. package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
  54. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
  55. package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
  56. package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
  57. package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
  58. package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
  59. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +152 -10
  60. package/tests/e2e/lifecycle.test.js +21 -10
  61. package/tests/snapshot/baseline.json +42 -38
  62. package/tests/snapshot/update-baseline.js +2 -1
  63. package/tests/unit/adr-decision-coverage.test.js +137 -0
  64. package/tests/unit/adr-scanner-contract.test.js +108 -0
  65. package/tests/unit/autocommit-deletion.test.js +242 -0
  66. package/tests/unit/check-docs-complete.test.js +249 -0
  67. package/tests/unit/check-growth-trace.test.js +188 -0
  68. package/tests/unit/check-ports.test.js +184 -0
  69. package/tests/unit/db-port-rule.test.js +216 -0
  70. package/tests/unit/detect-parse-anchor.test.js +109 -0
  71. package/tests/unit/external-dependency-check.test.js +209 -0
  72. package/tests/unit/growth-axes-and-compliance.test.js +169 -0
  73. package/tests/unit/growth-gate-conditional.test.js +122 -0
  74. package/tests/unit/growth-module-b2b-gate.test.js +122 -0
  75. package/tests/unit/growth-requirements-bridge.test.js +127 -0
  76. package/tests/unit/hooks-project-anchored.test.js +223 -0
  77. package/tests/unit/hooks-report-failures.test.js +207 -0
  78. package/tests/unit/module-copy-identity.test.js +76 -0
  79. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  80. package/tests/unit/roadmap-one-schema.test.js +179 -0
  81. package/tests/unit/skill-paths-prebaked.test.js +174 -0
  82. package/tests/unit/sparc-reconciliation.test.js +117 -0
  83. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  84. package/tests/unit/statusline-honest-labels.test.js +178 -0
  85. package/tests/unit/statusline-two-roots.test.js +237 -0
  86. package/tests/unit/sync-templates-guard.test.js +209 -0
  87. package/tests/unit/utils.test.js +2 -2
  88. package/tests/unit/validation-gate-teeth.test.js +158 -0
  89. package/LICENSE +0 -21
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ // Two callers disabled the exact branch the module they call declares. MEASURED:
4
+ //
5
+ // commands/replicate.md:169 | M5: Growth Engine | If B2C/PLG | …
6
+ // agents/product-discoverer.md:22 | M5: Growth Engine | If B2C/PLG | …
7
+ // modules/05-growth-engine.md:62 - Если B2B → sales-led growth, не product-led; переключи framework
8
+ // reverse-engineering-unicorn/SKILL.md:94 MODULE 5: GROWTH ENGINE → … (no condition)
9
+ //
10
+ // Three files disagreed about whether M5 runs, and the two that said "only for B2C" were the ones
11
+ // that decided. For every B2B project the one output slot — product-discoverer.md "### Growth
12
+ // Channels [From growth engine analysis — if applicable]" — arrived empty, not because there was
13
+ // nothing to say but because the gate stopped the module that would have said it.
14
+ //
15
+ // NOT done, deliberately: editing the growth module. It exists in four byte-identical copies across
16
+ // three published packages, kept in step by a --check gate that exits 1 on drift. A two-line fix in
17
+ // two callers touches ONE package; touching the module is a three-package release. P5 pins that.
18
+
19
+ const { test, describe } = require('node:test');
20
+ const assert = require('node:assert/strict');
21
+ const crypto = require('node:crypto');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
26
+ const read = (rel) => fs.readFileSync(path.join(TPL, rel), 'utf-8');
27
+
28
+ const CALLERS = [
29
+ { file: 'commands/replicate.md', produces: 'Channels, integrations' },
30
+ { file: 'agents/product-discoverer.md', produces: 'Channels, integrations, viral loops' },
31
+ ];
32
+ const MODULE = 'skills/reverse-engineering-unicorn/modules/05-growth-engine.md';
33
+
34
+ /** EVERY table row that decides whether M5 runs. Cross-family QE: taking the FIRST match let a gate
35
+ * come back in a duplicate row further down, invisible to both helpers. */
36
+ function m5Rows(file) {
37
+ const rows = read(file).split('\n').filter((l) => l.startsWith('| M5: Growth Engine |'));
38
+ assert.equal(rows.length, 1,
39
+ file + ' must have exactly one M5 row; found ' + rows.length + ': ' + JSON.stringify(rows));
40
+ return rows;
41
+ }
42
+ const cells = (row) => row.split('|').map((c) => c.trim());
43
+
44
+ describe('the growth module is not gated off for the type it handles itself', () => {
45
+ test('P1 — neither caller conditions M5 on a product type', () => {
46
+ // Asserted on the ROW, not the file: the phrase "B2C" appears legitimately elsewhere (the
47
+ // sentence explaining WHY the gate went), and a whole-file check would forbid the explanation.
48
+ // The property is not "no condition" — cross-family QE was right that `Always` over-promises,
49
+ // because M5's outputs are CAC, channels and loops and an internal tool with no acquisition
50
+ // objective has nothing to put in them. The property is that the condition does not EXCLUDE a
51
+ // product type the module handles: if it names types at all, B2B must be among them.
52
+ for (const { file } of CALLERS) {
53
+ for (const row of m5Rows(file)) {
54
+ const condition = cells(row)[2];
55
+ assert.notEqual(condition, '', file + ': the M5 condition cell is empty');
56
+ const namesTypes = /B2C|B2B|PLG|product-led|sales-led/i.test(condition);
57
+ if (namesTypes) {
58
+ assert.match(condition, /B2B/i,
59
+ file + ' gates M5 on product type and leaves B2B out — the module handles B2B at '
60
+ + 'modules/05-growth-engine.md:62: ' + JSON.stringify(condition));
61
+ }
62
+ assert.ok(!/^If B2C/i.test(condition),
63
+ file + ' restored the original product-type gate: ' + JSON.stringify(condition));
64
+ }
65
+ }
66
+ });
67
+
68
+ test('P2 — the row keeps saying what M5 produces', () => {
69
+ // A removal that took the row with it would satisfy P1 and lose the module from the table.
70
+ // The OUTPUT COLUMN, not a substring of the row: cross-family QE noted a substring check passes
71
+ // if the text drifts into the condition cell.
72
+ for (const { file, produces } of CALLERS) {
73
+ for (const row of m5Rows(file)) {
74
+ assert.equal(cells(row)[3], produces,
75
+ file + ' lost or moved M5\'s output column: ' + row);
76
+ }
77
+ }
78
+ });
79
+
80
+ test('P3 — the orchestrator still runs M5 unconditionally: it is the fixed point', () => {
81
+ const skill = read('skills/reverse-engineering-unicorn/SKILL.md');
82
+ const line = skill.split('\n').find((l) => l.includes('MODULE 5: GROWTH ENGINE'));
83
+ assert.ok(line, 'SKILL.md must still list MODULE 5');
84
+ assert.ok(!/if |если |B2C|B2B|PLG/i.test(line),
85
+ 'the orchestrator must stay unconditional — all three files agree with IT: ' + line);
86
+ });
87
+
88
+ test('P4 — the module\'s own B2B branch survives the removal that it justifies', () => {
89
+ // This is the load-bearing one. Removing the gate is safe BECAUSE the module decides per type.
90
+ // Without this assertion someone could delete the justification and keep the removal, and the
91
+ // module would then run for B2B with no B2B behaviour.
92
+ // REWORDED 2026-08-27 by growth-list-and-compliance. The old wording said "sales-led growth,
93
+ // не product-led", treating MOTION and LOOP as one exclusive choice — which the axis split had
94
+ // just refuted, so a B2B company with a sales-led motion and a product-led loop was told to
95
+ // reject the latter. Cross-family review caught the contradiction. The INTENT this test defends
96
+ // is unchanged and still asserted below: the module branches on type, which is what makes the
97
+ // ungated call correct. Only the wording moved, and it now says which axis it speaks about.
98
+ assert.match(read(MODULE), /Если B2B → мотион почти всегда sales-led или partnership-led/,
99
+ 'the branch that makes the ungated call correct must still be there');
100
+ assert.match(read(MODULE), /ТОЛЬКО про ось 1: петля выбирается отдельно/,
101
+ 'and it must scope itself to one axis, or it re-asserts the exclusivity the split removed');
102
+ });
103
+
104
+ test('P5 — the growth module is byte-identical: this feature touches two callers only', () => {
105
+ // Four byte-identical copies across three published packages hang off this file. Pinning its
106
+ // hash is what keeps a two-line fix from becoming a three-package release by accident.
107
+ //
108
+ // MOVED ONCE, deliberately, 2026-08-27 by growth-requirements-bridge, which added the
109
+ // `Growth Requirements Seed` section and IS the three-package release this message demands.
110
+ // MOVED A THIRD TIME by prebake-skill-paths: the module's own /mnt/ references became
111
+ // .claude/skills/ paths. Pins: 98e8577a… → bd9cfcb8… → 6657dbe2… → this.
112
+ // MOVED AGAIN, same day, by growth-list-and-compliance (two axes + the compliance
113
+ // checklist) — also a three-package release. Pins so far: 98e8577a… → bd9cfcb8… → this.
114
+ // Previous pin: 98e8577a… The tripwire is not weakened by the move — re-pinning is the
115
+ // conscious decision it exists to force, and the accompanying module-copy-identity.test.js now
116
+ // also proves all four copies still agree, which a single hash never could.
117
+ const sha = crypto.createHash('sha256')
118
+ .update(fs.readFileSync(path.join(TPL, MODULE))).digest('hex');
119
+ assert.equal(sha, '84658390dc7beb590f70164b391ace915e42926660bc1015e4be300752b74a37',
120
+ 'the growth module changed; that is a three-package release, not this feature');
121
+ });
122
+ });
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // The M5 growth analysis reached Phase 1 as an in-conversation --product-brief value and was never
4
+ // written to disk (sparc-prd-mini/SKILL.md:993-999). MEASURED before this feature:
5
+ // `grep -rn 'growth' sparc-prd-mini/ requirements-validator/` returned 0 hits in these templates.
6
+ //
7
+ // So the filed diagnosis — "no downstream step is obliged to read the analysis" — was one layer off.
8
+ // There was nothing to read. A seed section alone would have written obligations into an artifact
9
+ // that evaporates, which is why P1 assertions here are paired with the persistence ones.
10
+
11
+ const { test, describe } = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
17
+ const REPLICATE = path.join(TPL, 'commands', 'replicate.md');
18
+ const MODULE = path.join(TPL, 'skills', 'reverse-engineering-unicorn', 'modules', '05-growth-engine.md');
19
+
20
+ const read = (f) => fs.readFileSync(f, 'utf-8');
21
+
22
+ /** The seed section, from its heading to the next top-level one. */
23
+ function seedSection() {
24
+ const src = read(MODULE);
25
+ const start = src.indexOf('## 🌱 Growth Requirements Seed');
26
+ assert.ok(start > 0, '05-growth-engine.md must carry the seed section');
27
+ const end = src.indexOf('\n## ', start + 1);
28
+ assert.ok(end > start, 'the seed section must be followed by another section');
29
+ return src.slice(start, end);
30
+ }
31
+
32
+ describe('the growth analysis becomes a written, traceable obligation', () => {
33
+ test('P1 - Phase 0 names and writes the brief path', () => {
34
+ const src = read(REPLICATE);
35
+ assert.match(src, /docs\/product-discovery-brief\.md/,
36
+ 'Phase 0 must name the exact path it writes, or no consumer can find it');
37
+
38
+ // ORDER is the property, not mere presence: writing after the hand-off would leave Phase 1
39
+ // running against a brief the file does not yet contain.
40
+ const write = src.indexOf('**Write** the full Product Discovery Brief');
41
+ const hand = src.indexOf('pass it to Phase 1 as pre-filled context');
42
+ assert.ok(write > 0 && hand > write,
43
+ 'the write must be instructed BEFORE the hand-off: write=' + write + ' handoff=' + hand);
44
+ });
45
+
46
+ test('P2 - the in-context hand-off is preserved, not replaced', () => {
47
+ // FR-A2. Replacing the hand-off with a file read would change what Phase 1 receives and silently
48
+ // break the documented pre-filled-context contract in sparc-prd-mini.
49
+ const src = read(REPLICATE);
50
+ assert.match(src, /pre-filled context/,
51
+ 'the hand-off must survive — the file is an ADDITION');
52
+ assert.match(src, /the file is an ADDITION/i,
53
+ 'and the artifact must say so, so a later editor does not "simplify" one of the two away');
54
+ });
55
+
56
+ test('P3 - an absent brief means Phase 0 did not run, and the artifact says so', () => {
57
+ // FR-A3. The --from-docs entry skips Phase 0 entirely. Absence read as "no growth requirements"
58
+ // would penalise every --from-docs project forever.
59
+ const src = read(REPLICATE);
60
+ assert.match(src, /absent it means Phase 0 did not run/i,
61
+ 'absence must be given its meaning where the path is defined');
62
+ assert.match(src, /NOT evidence that the\s+project has no growth requirements/i,
63
+ 'and the wrong reading must be refused explicitly');
64
+ });
65
+
66
+ test('P4 - the seed emits ids with a defined form', () => {
67
+ const s = seedSection();
68
+ assert.match(s, /FR-GROWTH-<nnn>/, 'the id form must be stated');
69
+ assert.match(s, /три цифры, по порядку, номер не переиспользуется/,
70
+ 'an id without a uniqueness rule is an intention, not an identifier');
71
+ assert.match(s, /FR-GROWTH-001/, 'and the table must show a concrete row');
72
+ });
73
+
74
+ test('P5 - every row must name its source block', () => {
75
+ const s = seedSection();
76
+ assert.match(s, /Блок-источник/, 'the table needs a source column');
77
+ assert.match(s, /Требование без источника непрослеживаемо/,
78
+ 'and the rule must say a sourceless row is not a seed at all');
79
+ });
80
+
81
+ test('P6 - confidence is carried verbatim, never recomputed', () => {
82
+ // The module declares NO numeric threshold and confidence is not one scale across modes
83
+ // (SKILL.md:60-64 — QUICK is a manual X/5, DEEP is a formula). Normalising would invent a number
84
+ // nobody measured, so the rule is verbatim carry plus the SHIPPED [H] convention for doubt.
85
+ const s = seedSection();
86
+ assert.match(s, /Confidence переносится ДОСЛОВНО/, 'verbatim carry must be the rule');
87
+ assert.match(s, /Не пересчитывайте/, 'and recomputation must be refused');
88
+ assert.match(s, /\[H\]/, 'SPECULATIVE must hang on the shipped [H] convention');
89
+ assert.match(s, /НЕ НАЙДЕНО/, 'and on the shipped not-found convention');
90
+ assert.ok(!/confidence\s*[<>]\s*0\.\d/i.test(s),
91
+ 'a numeric threshold would be a convention invented here: ' + s.slice(0, 80));
92
+ });
93
+
94
+ test('P7 - the section states BOTH limits in the shipped artifact', () => {
95
+ // A seed table looks like proof of work planned. It is neither proof of building nor of legality.
96
+ const s = seedSection();
97
+ assert.match(s, /Черновик ≠ построено/, 'draft-is-not-built must be written where a reader sees it');
98
+ // RECONCILED 2026-08-27. The first version said flatly "Законность НЕ проверена" — true then,
99
+ // and false the moment the same feature-day added the ⚖️ compliance checklist, which DOES ask.
100
+ // Cross-family review found the contradiction: the seed disclaimed a question the module now
101
+ // puts to the user. The replacement is narrower AND stronger — asked, recorded, not established
102
+ // — and the distinction it draws is the load-bearing half.
103
+ assert.match(s, /Допустимость СПРОШЕНА, но не установлена/,
104
+ 'the seed must say the question is asked, since the checklist asks it');
105
+ assert.match(s, /Это не юридическое заключение/,
106
+ 'and must refuse to be read as a legal opinion');
107
+ assert.match(s, /не «это законно»/,
108
+ 'the difference between "seven questions cleared" and "lawful" is the whole point');
109
+ assert.ok(!/Законность НЕ проверена/.test(s),
110
+ 'the old flat disclaimer now contradicts the checklist and must not survive beside it');
111
+ });
112
+
113
+ test('P8 - an empty seed must be written, not omitted', () => {
114
+ // An absent table and a table saying "nothing to seed" are indistinguishable to the next reader,
115
+ // and the checker depends on being able to tell them apart.
116
+ const s = seedSection();
117
+ assert.match(s, /Пустая таблица — тоже ответ/, 'the empty case must be given a shape');
118
+ assert.match(s, /написана словом `нет`/, 'and a concrete token to write');
119
+ });
120
+
121
+ test('P9 - the seed says where it goes, closing the loop it was built to close', () => {
122
+ const s = seedSection();
123
+ assert.match(s, /docs\/product-discovery-brief\.md/, 'the seed must name its carrier file');
124
+ assert.match(s, /docs\/Specification\.md/, 'and its destination');
125
+ assert.match(s, /Фаза 2 проверяет/, 'and name the phase that checks the promotion happened');
126
+ });
127
+ });
@@ -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
+ });