@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-isolation-answer.js. Run: node isolation-answer.test.js
3
+ // No framework: plain asserts, same style as work-state.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. Pure decide() / parseOthers() / parseArgs().
7
+ // 2. main() with injected cwd/stdout/now against mkdtemp folders, incl. a real
8
+ // linked worktree resolving to the main checkout's record.
9
+ // 3. Fault injection: an fs whose write or rename throws.
10
+ // 4. End-to-end: execFileSync of the script itself.
11
+ const assert = require('assert');
12
+ const { execFileSync, spawnSync } = require('child_process');
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+
17
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-isolation-answer.js');
18
+ const { decide, parseOthers, parseArgs, resolveProjectRoot, main, IDLE_WINDOW_MS } = require(scriptPath);
19
+
20
+ let passed = 0;
21
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
22
+
23
+ const HOUR = 3600 * 1000;
24
+ const T0 = new Date('2026-10-14T08:00:00.000Z');
25
+ const at = (ms) => new Date(T0.getTime() + ms);
26
+ const iso = (d) => d.toISOString();
27
+
28
+ function mktmp() {
29
+ return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-isolation-answer-test-')));
30
+ }
31
+
32
+ // Runs main() capturing stdout and stderr; returns { code, lines, err }.
33
+ function run(argv, { cwd, now = T0, fsMod } = {}) {
34
+ const chunks = [];
35
+ const errChunks = [];
36
+ const realErr = process.stderr.write.bind(process.stderr);
37
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
38
+ let code;
39
+ try {
40
+ code = main(argv, { cwd, now, stdout: { write: (s) => chunks.push(s) }, ...(fsMod ? { fs: fsMod } : {}) });
41
+ } finally {
42
+ process.stderr.write = realErr;
43
+ }
44
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
45
+ return { code, lines, err: errChunks.join('') };
46
+ }
47
+
48
+ const recPath = (dir) => path.join(dir, '.trailhead', 'isolation-answer.json');
49
+ const readRec = (dir) => JSON.parse(fs.readFileSync(recPath(dir), 'utf8'));
50
+ const trailheadFiles = (dir) => fs.existsSync(path.join(dir, '.trailhead')) ? fs.readdirSync(path.join(dir, '.trailhead')) : [];
51
+ const rec = (over = {}) => ({ answer: 'no', others: [], recordedAt: iso(T0), usedAt: iso(T0), ...over });
52
+
53
+ // --- decide: every reason ----------------------------------------------------
54
+ {
55
+ ok('window is 8 hours', IDLE_WINDOW_MS === 8 * HOUR);
56
+ const d = (r, o, n = T0) => decide(r, o, n);
57
+ ok('decide absent', d(null, []).status === 'ask' && d(null, []).reason === 'absent');
58
+ ok('decide absent (undefined)', d(undefined, []).reason === 'absent');
59
+ ok('decide reuse no', d(rec(), []).status === 'reuse' && d(rec(), []).answer === 'no');
60
+ ok('decide reuse yes', d(rec({ answer: 'yes', others: [3] }), [3]).answer === 'yes');
61
+ ok('decide reuse carries recordedAt', d(rec(), []).recordedAt === iso(T0));
62
+ ok('decide unavailable always asks', d(rec(), 'unavailable').reason === 'unavailable');
63
+ ok('decide unavailable asks even with no record', d(null, 'unavailable').reason === 'unavailable');
64
+ ok('decide claims-changed (appeared)', d(rec(), [5]).reason === 'claims-changed');
65
+ ok('decide claims-changed (released)', d(rec({ others: [5] }), []).reason === 'claims-changed');
66
+ ok('decide claims-changed (swapped)', d(rec({ others: [5] }), [6]).reason === 'claims-changed');
67
+ ok('decide fingerprint ignores order and duplicates',
68
+ d(rec({ others: [9, 3, 3] }), [3, 9]).status === 'reuse' && d(rec({ others: [3, 9] }), [9, 3, 9]).status === 'reuse');
69
+ // idle window edge
70
+ ok('decide exactly 8h reuses', d(rec(), [], at(8 * HOUR)).status === 'reuse');
71
+ ok('decide just over 8h expires', d(rec(), [], at(8 * HOUR + 1)).reason === 'expired');
72
+ ok('decide slid usedAt keeps it alive', d(rec({ usedAt: iso(at(7 * HOUR)) }), [], at(14 * HOUR)).status === 'reuse');
73
+ // invalid records
74
+ for (const [name, r] of [
75
+ ['non-object', 'x'],
76
+ ['array', [1]],
77
+ ['bad answer', rec({ answer: 'maybe' })],
78
+ ['missing answer', { others: [], recordedAt: iso(T0), usedAt: iso(T0) }],
79
+ ['others not array', rec({ others: '3' })],
80
+ ['others zero', rec({ others: [0] })],
81
+ ['others negative', rec({ others: [-2] })],
82
+ ['others float', rec({ others: [1.5] })],
83
+ ['others string member', rec({ others: ['3'] })],
84
+ ['malformed usedAt', rec({ usedAt: 'yesterday' })],
85
+ ['malformed recordedAt', rec({ recordedAt: 'nope' })],
86
+ ['non-string usedAt', rec({ usedAt: 12 })],
87
+ ['non-ISO usedAt Date.parse accepts', rec({ usedAt: '2026' })],
88
+ ['non-ISO recordedAt with offset', rec({ recordedAt: '2026-10-14 10:00:00' })],
89
+ ['future usedAt', rec({ usedAt: iso(at(HOUR)) })],
90
+ ['future recordedAt', rec({ recordedAt: iso(at(HOUR)) })],
91
+ ]) ok(`decide invalid: ${name}`, d(r, []).reason === 'invalid');
92
+ }
93
+
94
+ // --- parseOthers / parseArgs -------------------------------------------------
95
+ {
96
+ ok('parseOthers none', JSON.stringify(parseOthers('none', { allowUnavailable: false })) === '[]');
97
+ ok('parseOthers csv sorted deduped', JSON.stringify(parseOthers('12,3,3,7', { allowUnavailable: false })) === '[3,7,12]');
98
+ ok('parseOthers unavailable allowed for check', parseOthers('unavailable', { allowUnavailable: true }) === 'unavailable');
99
+ for (const bad of ['unavailable', '', '0', '-1', '1,,2', '1.5', 'a', '3,x', ' 3']) {
100
+ ok(`parseOthers rejects ${JSON.stringify(bad)}`, parseOthers(bad, { allowUnavailable: false }) === null);
101
+ }
102
+ ok('parseOthers rejects empty even for check', parseOthers('', { allowUnavailable: true }) === null);
103
+ const p = parseArgs(['check', '--repo', '/x', '--others', 'none']);
104
+ ok('parseArgs check', p.command === 'check' && p.repo === '/x' && JSON.stringify(p.others) === '[]');
105
+ const r = parseArgs(['record', '--answer', 'yes', '--others', '4,2']);
106
+ ok('parseArgs record', r.answer === 'yes' && JSON.stringify(r.others) === '[2,4]');
107
+ ok('parseArgs clear', parseArgs(['clear']).command === 'clear');
108
+ for (const argv of [
109
+ [], ['frobnicate'], ['check'], ['check', '--others'], ['check', '--others', 'x'], ['check', '--others', 'none', '--bogus', '1'],
110
+ ['record', '--others', 'none'], ['record', '--answer', 'yes'], ['record', '--answer', 'maybe', '--others', 'none'],
111
+ ['record', '--answer', 'yes', '--others', 'unavailable'], ['clear', '--others', 'none'], ['clear', '--repo'],
112
+ ]) ok(`parseArgs error: ${argv.join(' ')}`, !!parseArgs(argv).error);
113
+ }
114
+
115
+ // --- main: record / check round trip ----------------------------------------
116
+ {
117
+ const dir = mktmp();
118
+ const r0 = run(['check', '--repo', dir, '--others', 'none'], { cwd: '/' });
119
+ ok('check absent: exit 0, ask absent', r0.code === 0 && r0.lines[0].status === 'ask' && r0.lines[0].reason === 'absent');
120
+ ok('check absent: path reported, nothing written', r0.lines[0].path === recPath(dir) && !fs.existsSync(path.join(dir, '.trailhead')));
121
+
122
+ const w = run(['record', '--repo', dir, '--answer', 'no', '--others', '9,4'], { cwd: '/' });
123
+ const s = readRec(dir);
124
+ ok('record: exit 0, written', w.code === 0 && w.lines.length === 1 && w.lines[0].status === 'written');
125
+ ok('record: output carries path and record', w.lines[0].path === recPath(dir) && w.lines[0].record.answer === 'no');
126
+ ok('record: exact key set', JSON.stringify(Object.keys(s)) === JSON.stringify(['answer', 'others', 'recordedAt', 'usedAt']));
127
+ ok('record: values', s.answer === 'no' && JSON.stringify(s.others) === '[4,9]' && s.recordedAt === iso(T0) && s.usedAt === iso(T0));
128
+ ok('record: no temp left', JSON.stringify(trailheadFiles(dir)) === JSON.stringify(['isolation-answer.json']));
129
+
130
+ const r1 = run(['check', '--repo', dir, '--others', '4,9'], { cwd: '/', now: at(2 * HOUR) });
131
+ ok('check reuse: status reuse with answer', r1.code === 0 && r1.lines[0].status === 'reuse' && r1.lines[0].answer === 'no');
132
+ ok('check reuse: recordedAt and path', r1.lines[0].recordedAt === iso(T0) && r1.lines[0].path === recPath(dir));
133
+ ok('check reuse: usedAt slid, recordedAt kept', readRec(dir).usedAt === iso(at(2 * HOUR)) && readRec(dir).recordedAt === iso(T0));
134
+
135
+ // the slide keeps a long working stretch alive past 8h since the first answer
136
+ const r2 = run(['check', '--repo', dir, '--others', '9,4'], { cwd: '/', now: at(9 * HOUR) });
137
+ ok('slide: 9h after record but 7h after last use still reuses', r2.lines[0].status === 'reuse');
138
+
139
+ const r3 = run(['check', '--repo', dir, '--others', '4'], { cwd: '/', now: at(9 * HOUR) });
140
+ ok('check claims-changed', r3.lines[0].status === 'ask' && r3.lines[0].reason === 'claims-changed');
141
+ ok('check ask does not touch usedAt', readRec(dir).usedAt === iso(at(9 * HOUR)));
142
+ const r4 = run(['check', '--repo', dir, '--others', 'unavailable'], { cwd: '/', now: at(9 * HOUR) });
143
+ ok('check unavailable', r4.lines[0].status === 'ask' && r4.lines[0].reason === 'unavailable');
144
+ const r5 = run(['check', '--repo', dir, '--others', '4,9'], { cwd: '/', now: at(9 * HOUR + 8 * HOUR + 1) });
145
+ ok('check expired', r5.lines[0].status === 'ask' && r5.lines[0].reason === 'expired');
146
+
147
+ // a new answer replaces the previous record
148
+ run(['record', '--repo', dir, '--answer', 'yes', '--others', 'none'], { cwd: '/', now: at(20 * HOUR) });
149
+ ok('record replaces', readRec(dir).answer === 'yes' && readRec(dir).others.length === 0 && readRec(dir).recordedAt === iso(at(20 * HOUR)));
150
+
151
+ // clear
152
+ const c = run(['clear', '--repo', dir], { cwd: '/' });
153
+ ok('clear present: cleared, gone', c.code === 0 && c.lines[0].status === 'cleared' && !fs.existsSync(recPath(dir)));
154
+ ok('clear absent', run(['clear', '--repo', dir], { cwd: '/' }).lines[0].status === 'absent');
155
+ }
156
+
157
+ // --- main: --repo defaults to cwd; corrupt file ------------------------------
158
+ {
159
+ const dir = mktmp();
160
+ run(['record', '--answer', 'yes', '--others', 'none'], { cwd: dir });
161
+ ok('repo defaults to cwd', readRec(dir).answer === 'yes');
162
+ fs.writeFileSync(recPath(dir), '{not json');
163
+ const r = run(['check', '--others', 'none'], { cwd: dir });
164
+ ok('corrupt file: ask invalid, exit 0', r.code === 0 && r.lines[0].reason === 'invalid');
165
+ fs.writeFileSync(recPath(dir), JSON.stringify(rec({ answer: 'nope' })));
166
+ ok('bad answer in file: invalid', run(['check', '--others', 'none'], { cwd: dir }).lines[0].reason === 'invalid');
167
+ }
168
+
169
+ // --- main: linked worktree resolves to the main checkout's record -----------
170
+ {
171
+ const main_ = mktmp();
172
+ const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
173
+ git(main_, 'init', '-q');
174
+ fs.writeFileSync(path.join(main_, 'f'), 'x');
175
+ git(main_, 'add', 'f');
176
+ git(main_, 'commit', '-q', '-m', 'init');
177
+ const wt = path.join(mktmp(), 'wt');
178
+ git(main_, 'worktree', 'add', '-q', '-b', 'trailhead/t1', wt);
179
+ ok('resolveProjectRoot: main checkout is itself', fs.realpathSync(resolveProjectRoot(main_)) === main_);
180
+ ok('resolveProjectRoot: worktree -> main checkout', fs.realpathSync(resolveProjectRoot(wt)) === main_);
181
+ const sub = path.join(main_, 'sub');
182
+ fs.mkdirSync(sub);
183
+ ok('resolveProjectRoot: subdir of main stays a dir of the same checkout', fs.realpathSync(resolveProjectRoot(sub)) === main_ || resolveProjectRoot(sub) === sub);
184
+
185
+ run(['record', '--repo', main_, '--answer', 'no', '--others', 'none'], { cwd: '/' });
186
+ const r = run(['check', '--repo', wt, '--others', 'none'], { cwd: '/', now: at(HOUR) });
187
+ ok('worktree call reads main record', r.lines[0].status === 'reuse' && fs.realpathSync(path.dirname(path.dirname(r.lines[0].path))) === main_);
188
+ ok('worktree gets no record of its own', !fs.existsSync(path.join(wt, '.trailhead', 'isolation-answer.json')));
189
+ run(['record', '--repo', wt, '--answer', 'yes', '--others', '2'], { cwd: '/', now: at(2 * HOUR) });
190
+ ok('worktree record lands in main', readRec(main_).answer === 'yes' && !fs.existsSync(path.join(wt, '.trailhead')));
191
+ }
192
+
193
+ // --- main: not a git repo uses --repo as given ------------------------------
194
+ {
195
+ const dir = mktmp();
196
+ ok('resolveProjectRoot: not a git repo -> as given', resolveProjectRoot(dir) === dir);
197
+ ok('resolveProjectRoot: missing dir -> as given', resolveProjectRoot(path.join(dir, 'nope')) === path.join(dir, 'nope'));
198
+ }
199
+
200
+ // --- usage errors: exit 2, nothing written ----------------------------------
201
+ for (const argv of [
202
+ ['check', '--others', 'x'],
203
+ ['check'],
204
+ ['record', '--answer', 'maybe', '--others', 'none'],
205
+ ['record', '--answer', 'yes', '--others', 'unavailable'],
206
+ ['record', '--answer', 'yes'],
207
+ ['clear', '--bogus', 'x'],
208
+ ['frobnicate'],
209
+ [],
210
+ ]) {
211
+ const dir = mktmp();
212
+ const withRepo = argv.length ? [argv[0], '--repo', dir, ...argv.slice(1)] : [];
213
+ const r = run(withRepo, { cwd: dir });
214
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2);
215
+ ok(`usage error writes stderr: ${argv.join(' ')}`, r.err.length > 0);
216
+ ok(`usage error writes nothing: ${argv.join(' ')}`, !fs.existsSync(path.join(dir, '.trailhead')));
217
+ }
218
+
219
+ // --- fault injection ---------------------------------------------------------
220
+ for (const failing of ['writeFileSync', 'renameSync']) {
221
+ const dir = mktmp();
222
+ run(['record', '--repo', dir, '--answer', 'no', '--others', 'none'], { cwd: '/' });
223
+ const bytes = fs.readFileSync(recPath(dir));
224
+ const broken = { ...fs, [failing]: () => { throw new Error('boom ' + failing); } };
225
+ if (failing === 'writeFileSync') broken.writeFileSync = () => { throw new Error('boom writeFileSync'); };
226
+
227
+ const w = run(['record', '--repo', dir, '--answer', 'yes', '--others', '3'], { cwd: '/', fsMod: broken });
228
+ ok(`record ${failing}: exit 0, skipped with message`, w.code === 0 && w.lines[0].status === 'skipped' && /boom/.test(w.lines[0].message));
229
+ ok(`record ${failing}: previous file byte-identical`, Buffer.compare(fs.readFileSync(recPath(dir)), bytes) === 0);
230
+ ok(`record ${failing}: no temp left`, JSON.stringify(trailheadFiles(dir)) === JSON.stringify(['isolation-answer.json']));
231
+
232
+ // a failing refresh on an otherwise valid reuse must never print reuse
233
+ const c = run(['check', '--repo', dir, '--others', 'none'], { cwd: '/', now: at(HOUR), fsMod: broken });
234
+ ok(`check ${failing}: exit 0, skipped not reuse`, c.code === 0 && c.lines.length === 1 && c.lines[0].status === 'skipped' && /boom/.test(c.lines[0].message));
235
+ ok(`check ${failing}: record untouched`, Buffer.compare(fs.readFileSync(recPath(dir)), bytes) === 0);
236
+ }
237
+ {
238
+ const dir = mktmp();
239
+ run(['record', '--repo', dir, '--answer', 'no', '--others', 'none'], { cwd: '/' });
240
+ const broken = { ...fs, readFileSync: () => { throw Object.assign(new Error('EACCES boom'), { code: 'EACCES' }); } };
241
+ const c = run(['check', '--repo', dir, '--others', 'none'], { cwd: '/', fsMod: broken });
242
+ ok('check unreadable: exit 0, skipped', c.code === 0 && c.lines[0].status === 'skipped' && /boom/.test(c.lines[0].message));
243
+ }
244
+
245
+ // --- end-to-end --------------------------------------------------------------
246
+ {
247
+ const dir = mktmp();
248
+ const out = execFileSync('node', [scriptPath, 'record', '--repo', dir, '--answer', 'yes', '--others', '7'], { encoding: 'utf8' });
249
+ ok('e2e: record one JSON line', JSON.parse(out.trim()).status === 'written');
250
+ const chk = execFileSync('node', [scriptPath, 'check', '--repo', dir, '--others', '7'], { encoding: 'utf8' });
251
+ ok('e2e: check reuse', JSON.parse(chk.trim()).status === 'reuse' && JSON.parse(chk.trim()).answer === 'yes');
252
+ const chg = execFileSync('node', [scriptPath, 'check', '--repo', dir, '--others', 'none'], { encoding: 'utf8' });
253
+ ok('e2e: check claims-changed', JSON.parse(chg.trim()).reason === 'claims-changed');
254
+ const bad = spawnSync('node', [scriptPath, 'check', '--repo', dir, '--others', 'nope'], { encoding: 'utf8' });
255
+ ok('e2e: usage error exits 2', bad.status === 2);
256
+ }
257
+
258
+ console.log(`isolation-answer.test.js: ${passed} checks passed`);
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-isolation-copy.js. Run: node isolation-copy.test.js
3
+ // No framework: plain asserts, same style as work-state.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. Pure helpers: checkRelPath(), readCopyList().
7
+ // 2. main() with injected cwd/env/stdout against mkdtemp folders (real git).
8
+ // 3. End-to-end: execFileSync of the script itself, global config via CLAUDE_CONFIG_DIR.
9
+ const assert = require('assert');
10
+ const { execFileSync, spawnSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+
15
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-isolation-copy.js');
16
+ const { checkRelPath, readCopyList, parseArgs, main } = require(scriptPath);
17
+
18
+ let passed = 0;
19
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
+
21
+ const mktmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-isolation-copy-test-'));
22
+ const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
23
+
24
+ // A main checkout with .gitignore, a tracked .env.example, and the given config.
25
+ function mkRepo(config, { files = {} } = {}) {
26
+ const dir = mktmp();
27
+ git(dir, 'init', '-q');
28
+ git(dir, 'config', 'user.email', 't@example.com');
29
+ git(dir, 'config', 'user.name', 't');
30
+ fs.writeFileSync(path.join(dir, '.gitignore'), '.env\n.npmrc\nconfig/local.json\nsecrets/\n');
31
+ fs.writeFileSync(path.join(dir, '.env.example'), 'A=\n');
32
+ fs.writeFileSync(path.join(dir, 'README.md'), 'x\n');
33
+ git(dir, 'add', '.gitignore', '.env.example', 'README.md');
34
+ git(dir, 'commit', '-q', '-m', 'init');
35
+ for (const [rel, content] of Object.entries(files)) {
36
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
37
+ fs.writeFileSync(path.join(dir, rel), content);
38
+ }
39
+ if (config !== undefined) {
40
+ fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
41
+ fs.writeFileSync(path.join(dir, '.trailhead', 'config.json'), typeof config === 'string' ? config : JSON.stringify(config));
42
+ }
43
+ return dir;
44
+ }
45
+
46
+ function run(argv, { env = {}, fsMod } = {}) {
47
+ const chunks = [];
48
+ const errChunks = [];
49
+ const realErr = process.stderr.write.bind(process.stderr);
50
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
51
+ let code;
52
+ try {
53
+ code = main(argv, { cwd: '/', env: { HOME: '/nonexistent-home', ...env }, stdout: { write: (s) => chunks.push(s) }, ...(fsMod ? { fs: fsMod } : {}) });
54
+ } finally {
55
+ process.stderr.write = realErr;
56
+ }
57
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
58
+ return { code, out: lines[0], lines, err: errChunks.join('') };
59
+ }
60
+
61
+ const emptyHome = mktmp(); // CLAUDE_CONFIG_DIR with no global config
62
+ const envNoGlobal = { CLAUDE_CONFIG_DIR: emptyHome };
63
+
64
+ // --- checkRelPath ------------------------------------------------------------
65
+ ok('plain file ok', checkRelPath('.env') === null);
66
+ ok('nested ok', checkRelPath('config/local.json') === null);
67
+ ok('absolute rejected', checkRelPath('/etc/passwd') !== null);
68
+ ok('.. rejected', checkRelPath('../x') !== null);
69
+ ok('inner .. rejected', checkRelPath('a/../../x') !== null);
70
+ ok('.git rejected', checkRelPath('.git') !== null);
71
+ ok('nested .git rejected', checkRelPath('a/.git/config') !== null);
72
+ ok('.gitignore is not a .git segment', checkRelPath('.gitignore') === null);
73
+
74
+ // --- readCopyList ------------------------------------------------------------
75
+ {
76
+ const d = mktmp();
77
+ const file = path.join(d, 'config.json');
78
+ const read = () => readCopyList(file, fs);
79
+ ok('absent file -> unset', read().state === 'unset');
80
+ fs.writeFileSync(file, '{"other":1}');
81
+ ok('no key -> unset', read().state === 'unset');
82
+ fs.writeFileSync(file, '{"isolation":{"copy":[".env"]}}');
83
+ ok('nested list', read().list.join() === '.env');
84
+ fs.writeFileSync(file, '{"isolation.copy":[".npmrc"]}');
85
+ ok('flat list', read().list.join() === '.npmrc');
86
+ fs.writeFileSync(file, '{"isolation":{"copy":[".env"]},"isolation.copy":[".npmrc"]}');
87
+ const both = read();
88
+ ok('nested wins over flat', both.list.join() === '.env');
89
+ ok('both shapes warn', both.warnings.length === 1 && /nested/.test(both.warnings[0]));
90
+ fs.writeFileSync(file, '{"isolation":{"copy":[]}}');
91
+ ok('empty list is set', read().state === 'set' && read().list.length === 0);
92
+ fs.writeFileSync(file, '{bad');
93
+ ok('bad JSON invalid', read().state === 'invalid' && read().message.length > 0);
94
+ fs.writeFileSync(file, '{"isolation":{"copy":".env"}}');
95
+ ok('non-array invalid', read().state === 'invalid');
96
+ fs.writeFileSync(file, '{"isolation":{"copy":[".env",3]}}');
97
+ ok('non-string entry invalid', read().state === 'invalid');
98
+ fs.writeFileSync(file, '{"isolation":{"copy":[""]}}');
99
+ ok('empty entry invalid', read().state === 'invalid');
100
+ }
101
+
102
+ // --- usage -------------------------------------------------------------------
103
+ ok('parseArgs unknown flag', parseArgs(['--bogus']).error !== undefined);
104
+ {
105
+ const r = run(['--from', '/tmp'], { env: envNoGlobal });
106
+ ok('missing --to is exit 2', r.code === 2 && r.lines.length === 0);
107
+ ok('unknown flag is exit 2', run(['--from', 'a', '--to', 'b', '--x'], { env: envNoGlobal }).code === 2);
108
+ }
109
+
110
+ // --- copy file + directory, present, missing ---------------------------------
111
+ {
112
+ const from = mkRepo({ isolation: { copy: ['.env', 'secrets', 'config/local.json', 'nope.txt'] } },
113
+ { files: { '.env': 'SECRET=1\n', 'secrets/a.key': 'k', 'config/local.json': '{}', 'secrets/.git/HEAD': 'ref', 'secrets/sub/b.key': 'b' } });
114
+ const to = mktmp();
115
+ fs.writeFileSync(path.join(to, '.env'), 'EXISTING\n');
116
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
117
+ ok('exit 0', r.code === 0);
118
+ ok('status copied', r.out.status === 'copied');
119
+ ok('copied list', r.out.copied.slice().sort().join() === 'config/local.json,secrets');
120
+ ok('present list', r.out.present.join() === '.env');
121
+ ok('missing list', r.out.missing.join() === 'nope.txt');
122
+ ok('existing not overwritten', fs.readFileSync(path.join(to, '.env'), 'utf8') === 'EXISTING\n');
123
+ ok('dest parent created', fs.readFileSync(path.join(to, 'config', 'local.json'), 'utf8') === '{}');
124
+ ok('dir copied recursively', fs.readFileSync(path.join(to, 'secrets', 'sub', 'b.key'), 'utf8') === 'b');
125
+ ok('nested .git skipped', !fs.existsSync(path.join(to, 'secrets', '.git')));
126
+ ok('no suggestions when set', r.out.suggestions.length === 0);
127
+ // Second run: everything now present.
128
+ const r2 = run(['--from', from, '--to', to], { env: envNoGlobal });
129
+ ok('second run nothing', r2.out.status === 'nothing' && r2.out.copied.length === 0 && r2.out.present.length === 3);
130
+ }
131
+
132
+ // --- empty list and rejections -----------------------------------------------
133
+ {
134
+ const from = mkRepo({ isolation: { copy: [] } });
135
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
136
+ ok('empty list nothing', r.out.status === 'nothing' && r.out.suggestions.length === 0);
137
+ }
138
+ {
139
+ const from = mkRepo({ isolation: { copy: ['/etc/passwd', '../x', '.git/config', 'a/.git/x', '.env'] } }, { files: { '.env': 'e' } });
140
+ const to = mktmp();
141
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
142
+ ok('rejected 4', r.out.rejected.length === 4);
143
+ ok('valid entry still copied', r.out.copied.join() === '.env');
144
+ }
145
+
146
+ // --- symlink safety ----------------------------------------------------------
147
+ {
148
+ // Source ancestor symlink escape: link -> outside dir, entry link/secret.
149
+ const outside = mktmp();
150
+ fs.writeFileSync(path.join(outside, 'secret'), 'top');
151
+ const from = mkRepo({ isolation: { copy: ['link/secret'] } });
152
+ fs.symlinkSync(outside, path.join(from, 'link'));
153
+ const to = mktmp();
154
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
155
+ ok('source ancestor symlink rejected', r.out.rejected.join() === 'link/secret' && r.out.copied.length === 0);
156
+ ok('nothing written for source escape', !fs.existsSync(path.join(to, 'link')));
157
+ }
158
+ {
159
+ // Destination ancestor symlink escape.
160
+ const outside = mktmp();
161
+ const from = mkRepo({ isolation: { copy: ['d/.env'] } }, { files: { 'd/.env': 'x' } });
162
+ const to = mktmp();
163
+ fs.symlinkSync(outside, path.join(to, 'd'));
164
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
165
+ ok('destination ancestor symlink rejected', r.out.rejected.join() === 'd/.env');
166
+ ok('nothing written outside', fs.readdirSync(outside).length === 0);
167
+ }
168
+ {
169
+ // Final symlink copied as a symlink, never followed.
170
+ const outside = mktmp();
171
+ fs.writeFileSync(path.join(outside, 'target'), 'private');
172
+ const from = mkRepo({ isolation: { copy: ['.env'] } });
173
+ fs.symlinkSync(path.join(outside, 'target'), path.join(from, '.env'));
174
+ const to = mktmp();
175
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
176
+ ok('final symlink copied', r.out.copied.join() === '.env');
177
+ ok('final symlink stays a symlink', fs.lstatSync(path.join(to, '.env')).isSymbolicLink());
178
+ ok('symlink verbatim', fs.readlinkSync(path.join(to, '.env')) === path.join(outside, 'target'));
179
+ }
180
+ {
181
+ // Nested symlink in a copied directory is verbatim.
182
+ const outside = mktmp();
183
+ const from = mkRepo({ isolation: { copy: ['secrets'] } }, { files: { 'secrets/a': 'a' } });
184
+ fs.symlinkSync(outside, path.join(from, 'secrets', 'ln'));
185
+ const to = mktmp();
186
+ run(['--from', from, '--to', to], { env: envNoGlobal });
187
+ ok('nested symlink verbatim', fs.lstatSync(path.join(to, 'secrets', 'ln')).isSymbolicLink() &&
188
+ fs.readlinkSync(path.join(to, 'secrets', 'ln')) === outside);
189
+ }
190
+
191
+ // --- invalid config: nothing copied, no hint ---------------------------------
192
+ for (const [label, cfg] of [['bad JSON', '{oops'], ['non-array', { isolation: { copy: '.env' } }], ['bad entry', { isolation: { copy: ['.env', 5] } }]]) {
193
+ const from = mkRepo(cfg, { files: { '.env': 'e' } });
194
+ const to = mktmp();
195
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal });
196
+ ok(`${label}: invalid`, r.code === 0 && r.out.status === 'invalid' && r.out.message.length > 0);
197
+ ok(`${label}: nothing copied, no suggestions`, r.out.copied.length === 0 && r.out.suggestions.length === 0 && !fs.existsSync(path.join(to, '.env')));
198
+ }
199
+
200
+ // --- precedence warning in output --------------------------------------------
201
+ {
202
+ const from = mkRepo({ isolation: { copy: ['.env'] }, 'isolation.copy': ['.npmrc'] }, { files: { '.env': 'e', '.npmrc': 'n' } });
203
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
204
+ ok('nested wins end to end', r.out.copied.join() === '.env' && r.out.warnings.length === 1);
205
+ }
206
+ {
207
+ const from = mkRepo({ 'isolation.copy': ['.env'] }, { files: { '.env': 'e' } });
208
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
209
+ ok('flat shape works', r.out.copied.join() === '.env' && r.out.warnings.length === 0);
210
+ }
211
+
212
+ // --- unset + hint -------------------------------------------------------------
213
+ {
214
+ const from = mkRepo(undefined, { files: { '.env': 'e' } });
215
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
216
+ ok('unset status', r.out.status === 'unset');
217
+ ok('ignored sibling suggested', r.out.suggestions.join() === '.env');
218
+ }
219
+ {
220
+ // Sibling absent -> not suggested; tracked sibling -> not suggested; .sample + nested.
221
+ const from = mkRepo(undefined);
222
+ fs.mkdirSync(path.join(from, 'config'));
223
+ fs.writeFileSync(path.join(from, 'config', 'local.json.sample'), '{}');
224
+ fs.writeFileSync(path.join(from, 'config', 'local.json'), '{}');
225
+ fs.writeFileSync(path.join(from, 'tracked.json.example'), '{}');
226
+ fs.writeFileSync(path.join(from, 'tracked.json'), '{}');
227
+ git(from, 'add', 'config/local.json.sample', 'tracked.json.example', 'tracked.json');
228
+ git(from, 'commit', '-q', '-m', 'more');
229
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
230
+ ok('nested .sample suggested', r.out.suggestions.join() === 'config/local.json');
231
+ }
232
+ {
233
+ // Untracked example is ignored (tracked examples only).
234
+ const from = mkRepo(undefined, { files: { '.npmrc': 'n', '.npmrc.example': 'n' } });
235
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
236
+ ok('untracked example not used', r.out.status === 'unset' && r.out.suggestions.length === 0);
237
+ }
238
+ {
239
+ // Not a git repo: suggestions empty, no error.
240
+ const from = mktmp();
241
+ const r = run(['--from', from, '--to', mktmp()], { env: envNoGlobal });
242
+ ok('git failure -> empty suggestions', r.code === 0 && r.out.status === 'unset' && r.out.suggestions.length === 0);
243
+ }
244
+ {
245
+ const r = run(['--from', path.join(mktmp(), 'gone'), '--to', mktmp()], { env: envNoGlobal });
246
+ ok('missing --from dir is skipped', r.code === 0 && ['skipped', 'unset'].includes(r.out.status));
247
+ }
248
+
249
+ // --- a failed copy leaves no partial destination ------------------------------
250
+ {
251
+ const from = mkRepo({ isolation: { copy: ['secrets'] } }, { files: { 'secrets/a': 'a', 'secrets/b': 'b' } });
252
+ const to = mktmp();
253
+ const failing = {
254
+ ...fs,
255
+ cpSync: (src, dst) => { fs.mkdirSync(dst); fs.writeFileSync(path.join(dst, 'a'), 'a'); throw new Error('boom'); },
256
+ };
257
+ const r = run(['--from', from, '--to', to], { env: envNoGlobal, fsMod: failing });
258
+ ok('failed copy not reported copied', r.out.copied.length === 0 && r.out.status === 'nothing');
259
+ ok('failed copy warns', r.out.warnings.some((w) => /secrets/.test(w) && /boom/.test(w)));
260
+ ok('partial destination removed', !fs.existsSync(path.join(to, 'secrets')));
261
+ const r2 = run(['--from', from, '--to', to], { env: envNoGlobal });
262
+ ok('retry copies it', r2.out.status === 'copied' && fs.existsSync(path.join(to, 'secrets', 'b')));
263
+ // Cleanup failure adds a warning.
264
+ const to2 = mktmp();
265
+ const stuck = { ...failing, rmSync: () => { throw new Error('nope'); } };
266
+ const r3 = run(['--from', from, '--to', to2], { env: envNoGlobal, fsMod: stuck });
267
+ ok('cleanup failure warns', r3.out.warnings.some((w) => /clean/.test(w)));
268
+ }
269
+
270
+ // --- end to end with a fake CLAUDE_CONFIG_DIR ---------------------------------
271
+ {
272
+ const cfgDir = mktmp();
273
+ fs.mkdirSync(path.join(cfgDir, 'trailhead'));
274
+ fs.writeFileSync(path.join(cfgDir, 'trailhead', 'config.json'), JSON.stringify({ isolation: { copy: ['.npmrc'] } }));
275
+ const exe = (from, to) => JSON.parse(execFileSync(process.execPath, [scriptPath, '--from', from, '--to', to],
276
+ { env: { ...process.env, CLAUDE_CONFIG_DIR: cfgDir } }).toString());
277
+
278
+ // Global inherited when the project has no isolation.copy.
279
+ const a = mkRepo({ other: true }, { files: { '.npmrc': 'n', '.env': 'e' } });
280
+ const toA = mktmp();
281
+ const ra = exe(a, toA);
282
+ ok('e2e global inherited', ra.status === 'copied' && ra.copied.join() === '.npmrc');
283
+
284
+ // Project value (even []) replaces global wholesale.
285
+ const b = mkRepo({ isolation: { copy: ['.env'] } }, { files: { '.npmrc': 'n', '.env': 'e' } });
286
+ const rb = exe(b, mktmp());
287
+ ok('e2e project replaces global', rb.copied.join() === '.env');
288
+ const c = mkRepo({ isolation: { copy: [] } }, { files: { '.npmrc': 'n' } });
289
+ const rc = exe(c, mktmp());
290
+ ok('e2e empty project list replaces global', rc.status === 'nothing');
291
+
292
+ // Usage error is exit 2.
293
+ const bad = spawnSync(process.execPath, [scriptPath, '--from', a], { env: { ...process.env, CLAUDE_CONFIG_DIR: cfgDir } });
294
+ ok('e2e usage exit 2', bad.status === 2);
295
+ ok('script is executable', (fs.statSync(scriptPath).mode & 0o111) !== 0);
296
+ }
297
+
298
+ console.log(`isolation-copy.test.js: ${passed} checks passed`);