@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,319 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-plan-review-rounds.js. Run: node plan-review-rounds.test.js
3
+ // No framework: plain asserts, same style as auto-slot.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. ask: config lookup, roster resolution, host drop, unknown names, options menu.
7
+ // 2. round: converged / continue / exhausted / no-progress / ceiling and their precedence.
8
+ // 3. Usage errors (exit 2).
9
+ // 4. End-to-end: the script itself through spawnSync.
10
+ const assert = require('assert');
11
+ const { spawnSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-plan-review-rounds.js');
17
+ const { main } = require(scriptPath);
18
+
19
+ let passed = 0;
20
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
21
+
22
+ function run(argv, deps = {}) {
23
+ let out = '';
24
+ let errOut = '';
25
+ const code = main(argv, { stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...deps });
26
+ let json = null;
27
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
28
+ return { code, out, json, errOut };
29
+ }
30
+
31
+ const tmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-review-rounds-')));
32
+ const rm = (d) => fs.rmSync(d, { recursive: true, force: true });
33
+
34
+ // A project root and a global config dir holding the given config objects (null = no file).
35
+ function setup(project, global) {
36
+ const root = tmp();
37
+ const gdir = tmp();
38
+ if (project !== null && project !== undefined) {
39
+ fs.mkdirSync(path.join(root, '.trailhead'), { recursive: true });
40
+ fs.writeFileSync(path.join(root, '.trailhead', 'config.json'), typeof project === 'string' ? project : JSON.stringify(project));
41
+ }
42
+ if (global !== null && global !== undefined) {
43
+ fs.mkdirSync(path.join(gdir, 'trailhead'), { recursive: true });
44
+ fs.writeFileSync(path.join(gdir, 'trailhead', 'config.json'), typeof global === 'string' ? global : JSON.stringify(global));
45
+ }
46
+ return { root, gdir, done: () => { rm(root); rm(gdir); } };
47
+ }
48
+
49
+ // Deps with a fixed set of binaries "on PATH" and a canned kimi --help.
50
+ const deps = (gdir, installed, kimiHelp = '--output-format json') => ({
51
+ env: { CLAUDE_CONFIG_DIR: gdir, PATH: '/nowhere' },
52
+ which: (name) => installed.includes(name),
53
+ kimiHelp: () => kimiHelp,
54
+ });
55
+
56
+ const ask = (s, installed, extra = [], kimiHelp) => run(['ask', '--repo', s.root, ...extra], deps(s.gdir, installed, kimiHelp));
57
+ const values = (r) => r.json.options.map((o) => o.value);
58
+
59
+ // --- ask: skip ---------------------------------------------------------------
60
+ {
61
+ const s = setup({}, null);
62
+ const r = ask(s, ['codex', 'gemini']);
63
+ ok('ask: plan_review unset is skip off', r.code === 0 && r.json.status === 'skip' && r.json.reason === 'off' && r.json.configured === 2);
64
+ s.done();
65
+ }
66
+ {
67
+ const s = setup({ plan_review: 'off', 'plan_review.rounds': 4 }, null);
68
+ const r = ask(s, ['codex']);
69
+ ok('ask: plan_review off is skip off with the configured rounds', r.json.status === 'skip' && r.json.reason === 'off' && r.json.configured === 4);
70
+ s.done();
71
+ }
72
+ {
73
+ const s = setup({ plan_review: 'on' }, null);
74
+ const r = ask(s, []);
75
+ ok('ask: on with nothing installed is skip no-reviewer', r.code === 0 && r.json.status === 'skip' && r.json.reason === 'no-reviewer');
76
+ const own = ask(s, ['claude']);
77
+ ok('ask: only the host CLI installed is skip no-reviewer', own.json.status === 'skip' && own.json.reason === 'no-reviewer');
78
+ const codexHost = ask(s, ['codex'], ['--host', 'codex']);
79
+ ok('ask: --host codex drops codex', codexHost.json.status === 'skip' && codexHost.json.reason === 'no-reviewer');
80
+ const claudeOnCodex = ask(s, ['claude'], ['--host', 'codex']);
81
+ ok('ask: --host codex keeps claude as an independent reviewer', claudeOnCodex.json.status === 'ask' && JSON.stringify(claudeOnCodex.json.reviewers) === '["claude"]');
82
+ s.done();
83
+ }
84
+
85
+ // --- ask: roster resolution --------------------------------------------------
86
+ {
87
+ const s = setup({ plan_review: 'on' }, null);
88
+ const r = ask(s, ['claude', 'codex', 'gemini', 'agy', 'cursor-agent', 'not-in-roster']);
89
+ ok('ask: on resolves the whole roster minus the host, in roster order', r.json.status === 'ask' && JSON.stringify(r.json.reviewers) === JSON.stringify(['gemini', 'codex', 'agy', 'cursor-agent']));
90
+ ok('ask: on ignores binaries outside the roster', !r.json.reviewers.includes('not-in-roster') && r.json.unknown.length === 0);
91
+ ok('ask: reports the configured count', r.json.configured === 2);
92
+ s.done();
93
+ }
94
+ {
95
+ const s = setup({ plan_review: 'codex, gemini' }, null);
96
+ const r = ask(s, ['codex', 'gemini', 'qwen']);
97
+ ok('ask: a comma list restricts the candidates', JSON.stringify(r.json.reviewers) === JSON.stringify(['codex', 'gemini']));
98
+ const only = ask(s, ['gemini']);
99
+ ok('ask: a listed CLI that is not installed is not counted', JSON.stringify(only.json.reviewers) === '["gemini"]' && only.json.unknown.length === 0);
100
+ s.done();
101
+ }
102
+
103
+ // --- ask: kimi probe ---------------------------------------------------------
104
+ {
105
+ const s = setup({ plan_review: 'kimi' }, null);
106
+ ok('ask: kimi counts when --help mentions --output-format', ask(s, ['kimi'], [], 'usage: kimi --output-format').json.status === 'ask');
107
+ const legacy = ask(s, ['kimi'], [], 'usage: kimi [--print]');
108
+ ok('ask: legacy kimi (no --output-format) does not count', legacy.json.status === 'skip' && legacy.json.reason === 'no-reviewer');
109
+ const noHelp = ask(s, ['kimi'], [], null);
110
+ ok('ask: a failing kimi --help does not count', noHelp.json.status === 'skip');
111
+ let probes = 0;
112
+ const o = run(['ask', '--repo', s.root], { ...deps(s.gdir, []), kimiHelp: () => { probes++; return '--output-format'; } });
113
+ ok('ask: kimi is not probed when its binary is absent', probes === 0 && o.json.status === 'skip');
114
+ s.done();
115
+ }
116
+
117
+ // --- ask: unknown names ------------------------------------------------------
118
+ {
119
+ const s = setup({ plan_review: 'codex,my-local,tpyo' }, null);
120
+ const r = ask(s, ['codex', 'my-local']);
121
+ ok('ask: a custom reviewer counts when its binary is on PATH', JSON.stringify(r.json.reviewers) === JSON.stringify(['codex', 'my-local']));
122
+ ok('ask: a custom reviewer on PATH is not unknown', !r.json.unknown.includes('my-local'));
123
+ ok('ask: an unknown name with no binary is listed, not counted, and warned', JSON.stringify(r.json.unknown) === '["tpyo"]' && !r.json.reviewers.includes('tpyo') && r.json.warnings.some((w) => w.includes('tpyo')));
124
+ const typoOnly = setup({ plan_review: 'tpyo' }, null);
125
+ const t = ask(typoOnly, ['codex']);
126
+ ok('ask: a list of only unknown absent names is skip no-reviewer, still reporting unknown', t.json.status === 'skip' && t.json.reason === 'no-reviewer' && JSON.stringify(t.json.unknown) === '["tpyo"]');
127
+ typoOnly.done();
128
+ s.done();
129
+ }
130
+
131
+ // --- ask: options menu -------------------------------------------------------
132
+ {
133
+ const mk = (rounds) => {
134
+ const s = setup({ plan_review: 'codex', 'plan_review.rounds': rounds }, null);
135
+ const r = ask(s, ['codex']);
136
+ s.done();
137
+ return r;
138
+ };
139
+ const r2 = mk(2);
140
+ ok('options: n=2 is config(2) Recommended first, then 1, 3, until', JSON.stringify(values(r2)) === JSON.stringify(['config', '1', '3', 'until']) && r2.json.options[0].recommended === true && r2.json.options[0].label === 'As in config (2)');
141
+ ok('options: only the first entry is recommended', r2.json.options.slice(1).every((o) => !o.recommended));
142
+ ok('options: every entry has a human label', r2.json.options.every((o) => typeof o.label === 'string' && o.label.length > 0));
143
+ ok('options: n=1 is config, 2, 3, until', JSON.stringify(values(mk(1))) === JSON.stringify(['config', '2', '3', 'until']));
144
+ ok('options: n=3 is config, 1, 2, until', JSON.stringify(values(mk(3))) === JSON.stringify(['config', '1', '2', 'until']));
145
+ const r5 = mk(5);
146
+ ok('options: n=5 is config(5), 1, 2, until', JSON.stringify(values(r5)) === JSON.stringify(['config', '1', '2', 'until']) && r5.json.options[0].label === 'As in config (5)');
147
+ ok('options: never more than four entries', [1, 2, 3, 4, 7, 12].every((n) => mk(n).json.options.length <= 4));
148
+ ok('options: the numbered-list fallback lists all five', JSON.stringify(r2.json.fallback.map((o) => o.value)) === JSON.stringify(['config', '1', '2', '3', 'until']));
149
+ }
150
+
151
+ // --- ask: rounds fallback ----------------------------------------------------
152
+ {
153
+ for (const bad of [0, -1, 1.5, 'two', null]) {
154
+ const s = setup({ plan_review: 'codex', 'plan_review.rounds': bad }, null);
155
+ const r = ask(s, ['codex']);
156
+ ok(`rounds: ${JSON.stringify(bad)} falls back to 2 with a warning`, r.code === 0 && r.json.configured === 2 && r.json.warnings.some((w) => w.includes('plan_review.rounds')));
157
+ s.done();
158
+ }
159
+ const s = setup({ plan_review: 'codex' }, null);
160
+ const r = ask(s, ['codex']);
161
+ ok('rounds: unset is 2 without a warning', r.json.configured === 2 && r.json.warnings.length === 0);
162
+ s.done();
163
+ }
164
+
165
+ // --- ask: config lookup ------------------------------------------------------
166
+ {
167
+ const s = setup({ 'plan_review.rounds': 3 }, { plan_review: 'codex', 'plan_review.rounds': 5 });
168
+ const r = ask(s, ['codex']);
169
+ ok('config: keys resolve one by one, nearest wins (global plan_review, project rounds)', r.json.status === 'ask' && r.json.configured === 3 && JSON.stringify(r.json.reviewers) === '["codex"]');
170
+ s.done();
171
+ }
172
+ {
173
+ const s = setup({ plan_review: 'off' }, { plan_review: 'on' });
174
+ ok('config: the project value beats the global one', ask(s, ['codex']).json.reason === 'off');
175
+ s.done();
176
+ }
177
+ {
178
+ const s = setup(null, { plan_review: 'on' });
179
+ ok('config: a global-only config applies', ask(s, ['codex']).json.status === 'ask');
180
+ s.done();
181
+ }
182
+ {
183
+ const s = setup({ plan_review: 'on' }, null);
184
+ const home = tmp();
185
+ fs.mkdirSync(path.join(home, '.claude', 'trailhead'), { recursive: true });
186
+ fs.writeFileSync(path.join(home, '.claude', 'trailhead', 'config.json'), JSON.stringify({ 'plan_review.rounds': 4 }));
187
+ const r = run(['ask', '--repo', s.root], { env: { HOME: home, PATH: '/nowhere' }, which: (n) => n === 'codex', kimiHelp: () => null });
188
+ ok('config: without CLAUDE_CONFIG_DIR the global file is ~/.claude/trailhead/config.json', r.json.configured === 4);
189
+ rm(home);
190
+ s.done();
191
+ }
192
+
193
+ // --- ask: invalid config -----------------------------------------------------
194
+ {
195
+ const bad = setup('{not json', null);
196
+ const r = ask(bad, ['codex']);
197
+ ok('error: an unparseable project config is status error, exit 1', r.code === 1 && r.json.status === 'error' && /JSON/.test(r.json.reason));
198
+ bad.done();
199
+ const arr = setup('[1]', null);
200
+ ok('error: a non-object config is status error', ask(arr, ['codex']).json.status === 'error');
201
+ arr.done();
202
+ const gl = setup({}, '{nope');
203
+ ok('error: an unparseable global config is status error', ask(gl, ['codex']).code === 1);
204
+ gl.done();
205
+ const typ = setup({ plan_review: 7 }, null);
206
+ ok('error: a non-string plan_review is status error', ask(typ, ['codex']).json.status === 'error');
207
+ typ.done();
208
+ const empty = setup({ plan_review: ' , ' }, null);
209
+ ok('error: a plan_review list with no name is status error', ask(empty, ['codex']).json.status === 'error');
210
+ empty.done();
211
+ }
212
+ {
213
+ const s = setup({ plan_review: 'on' }, null);
214
+ const failing = { readFileSync: () => { const e = new Error('EACCES: denied'); e.code = 'EACCES'; throw e; } };
215
+ const r = run(['ask', '--repo', s.root], { ...deps(s.gdir, ['codex']), fs: failing });
216
+ ok('error: an unreadable config (not ENOENT) is status error', r.code === 1 && r.json.status === 'error' && /cannot read/.test(r.json.reason));
217
+ s.done();
218
+ }
219
+
220
+ // --- ask: real PATH scan -----------------------------------------------------
221
+ {
222
+ const bin = tmp();
223
+ fs.writeFileSync(path.join(bin, 'gemini'), '#!/bin/sh\n', { mode: 0o755 });
224
+ fs.writeFileSync(path.join(bin, 'qwen'), '#!/bin/sh\n', { mode: 0o644 });
225
+ fs.mkdirSync(path.join(bin, 'codex'));
226
+ const s = setup({ plan_review: 'on' }, null);
227
+ const r = run(['ask', '--repo', s.root], { env: { CLAUDE_CONFIG_DIR: s.gdir, PATH: bin }, kimiHelp: () => null });
228
+ ok('path: the default scan finds executable files only', r.json.status === 'ask' && JSON.stringify(r.json.reviewers) === '["gemini"]');
229
+ s.done();
230
+ rm(bin);
231
+ }
232
+
233
+ // --- round -------------------------------------------------------------------
234
+ const c = (key, text) => ({ key, text: text || `full text of ${key}` });
235
+ function round(history, choice, configured, extra = {}) {
236
+ const dir = tmp();
237
+ const file = path.join(dir, 'history.json');
238
+ fs.writeFileSync(file, typeof history === 'string' ? history : JSON.stringify(history));
239
+ const r = run(['round', '--choice', choice, '--configured', String(configured), '--history', file], extra);
240
+ rm(dir);
241
+ return r;
242
+ }
243
+ const decision = (r) => r.json.decision;
244
+
245
+ ok('round: a latest round with no concern is converged', decision(round([[c('a')], []], '2', 2)) === 'converged' && round([[]], 'until', 2).json.rounds === 1);
246
+ ok('round: converged carries no surviving', !('surviving' in round([[]], '1', 2).json));
247
+ ok('round: a fixed choice with rounds left continues', decision(round([[c('a')]], '2', 2)) === 'continue' && !('surviving' in round([[c('a')]], '2', 2).json));
248
+ {
249
+ const r = round([[c('a', 'A is wrong because X'), c('b')], [c('a', 'A is still wrong because X')]], '2', 2);
250
+ ok('round: a fixed choice with rounds spent is exhausted', decision(r) === 'exhausted');
251
+ ok('round: exhausted surviving carries the full {key,text} of the latest round', JSON.stringify(r.json.surviving) === JSON.stringify([c('a', 'A is still wrong because X')]));
252
+ }
253
+ ok('round: config resolves to --configured', decision(round([[c('a')]], 'config', 1)) === 'exhausted' && decision(round([[c('a')]], 'config', 3)) === 'continue');
254
+ ok('round: exhausted is not reported for a fixed choice before its rounds', decision(round([[c('a')], [c('b')]], '3', 2)) === 'continue');
255
+ ok('round: a fixed choice never reports no-progress', decision(round([[c('a')], [c('a')]], '3', 2)) === 'continue');
256
+ ok('round: until continues while concerns change', decision(round([[c('a')], [c('a'), c('b')], [c('b')]], 'until', 2)) === 'continue');
257
+ ok('round: until with round 1 never reports no-progress', decision(round([[c('a')]], 'until', 2)) === 'continue');
258
+ {
259
+ const r = round([[c('a'), c('b')], [c('a'), c('b'), c('c', 'new one')]], 'until', 2);
260
+ ok('round: until with none of the previous concerns resolved is no-progress', decision(r) === 'no-progress' && r.json.surviving.length === 3 && r.json.surviving[2].text === 'new one');
261
+ }
262
+ ok('round: until with one previous concern resolved keeps going', decision(round([[c('a'), c('b')], [c('a')]], 'until', 2)) === 'continue');
263
+ ok('round: keys are compared normalised', decision(round([[c(' Plan Gap ')], [c('plan gap')]], 'until', 2)) === 'no-progress');
264
+ ok('round: until at 5 rounds with progress is ceiling', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], [c('e')]], 'until', 2)) === 'ceiling');
265
+ ok('round: ceiling carries surviving', round([[c('a')], [c('b')], [c('c')], [c('d')], [c('e')]], 'until', 2).json.surviving[0].key === 'e');
266
+ ok('round: until at 4 rounds still continues', decision(round([[c('a')], [c('b')], [c('c')], [c('d')]], 'until', 2)) === 'continue');
267
+ ok('round: precedence: converged beats ceiling', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], []], 'until', 2)) === 'converged');
268
+ ok('round: precedence: no-progress beats ceiling at round 5', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], [c('d')]], 'until', 2)) === 'no-progress');
269
+ ok('round: precedence: converged beats exhausted', decision(round([[c('a')], []], '1', 1)) === 'converged');
270
+ ok('round: output is one status ok JSON line with the round count', round([[c('a')]], '2', 2).json.status === 'ok' && round([[c('a')]], '2', 2).json.rounds === 1 && round([[c('a')]], '2', 2).out.trim().split('\n').length === 1);
271
+
272
+ // --- usage errors ------------------------------------------------------------
273
+ {
274
+ const u = (argv) => run(argv);
275
+ ok('usage: no command exits 2', u([]).code === 2);
276
+ ok('usage: unknown command exits 2', u(['go', '--repo', '/x']).code === 2);
277
+ ok('usage: ask without --repo exits 2', u(['ask']).code === 2);
278
+ ok('usage: ask with a bad --host exits 2', u(['ask', '--repo', '/x', '--host', 'vim']).code === 2);
279
+ ok('usage: unknown flag exits 2', u(['ask', '--repo', '/x', '--bogus', '1']).code === 2);
280
+ ok('usage: a flag without a value exits 2', u(['ask', '--repo']).code === 2);
281
+ ok('usage: round without --choice exits 2', u(['round', '--configured', '2', '--history', '/x']).code === 2);
282
+ ok('usage: round with a bad --choice exits 2', u(['round', '--choice', '4', '--configured', '2', '--history', '/x']).code === 2);
283
+ ok('usage: round without --configured exits 2', u(['round', '--choice', '1', '--history', '/x']).code === 2);
284
+ ok('usage: round with a non-positive --configured exits 2', u(['round', '--choice', '1', '--configured', '0', '--history', '/x']).code === 2);
285
+ ok('usage: round without --history exits 2', u(['round', '--choice', '1', '--configured', '2']).code === 2);
286
+ ok('usage: errors go to stderr, stdout stays empty', u(['ask']).errOut.startsWith('usage:') && u(['ask']).out === '');
287
+ ok('usage: history that is not JSON exits 2', round('{nope', '2', 2).code === 2);
288
+ ok('usage: history that is not an array exits 2', round({ a: 1 }, '2', 2).code === 2);
289
+ ok('usage: an empty history exits 2', round([], '2', 2).code === 2);
290
+ ok('usage: a round that is not an array exits 2', round([{}], '2', 2).code === 2);
291
+ ok('usage: a concern that is a bare string exits 2', round([['a']], '2', 2).code === 2);
292
+ ok('usage: a concern with an empty key exits 2', round([[{ key: ' ', text: 'x' }]], '2', 2).code === 2);
293
+ ok('usage: a concern with an empty text exits 2', round([[{ key: 'a', text: '' }]], '2', 2).code === 2);
294
+ ok('usage: a concern with a non-string text exits 2', round([[{ key: 'a', text: 3 }]], '2', 2).code === 2);
295
+ const missing = run(['round', '--choice', '1', '--configured', '2', '--history', '/nowhere/h.json']);
296
+ ok('usage: a missing history file is status error, exit 1', missing.code === 1 && missing.json.status === 'error');
297
+ }
298
+
299
+ // --- end-to-end --------------------------------------------------------------
300
+ {
301
+ const s = setup({ plan_review: 'on' }, null);
302
+ const bin = tmp();
303
+ fs.writeFileSync(path.join(bin, 'gemini'), '#!/bin/sh\n', { mode: 0o755 });
304
+ const res = spawnSync(process.execPath, [scriptPath, 'ask', '--repo', s.root], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: s.gdir, PATH: bin } });
305
+ const j = JSON.parse(res.stdout.trim());
306
+ ok('e2e: ask prints one JSON line and exits 0', res.status === 0 && j.status === 'ask' && JSON.stringify(j.reviewers) === '["gemini"]' && j.options.length === 4);
307
+ const dir = tmp();
308
+ const file = path.join(dir, 'h.json');
309
+ fs.writeFileSync(file, JSON.stringify([[c('a')]]));
310
+ const rr = spawnSync(process.execPath, [scriptPath, 'round', '--choice', '1', '--configured', '2', '--history', file], { encoding: 'utf8' });
311
+ const rj = JSON.parse(rr.stdout.trim());
312
+ ok('e2e: round prints the decision and exits 0', rr.status === 0 && rj.decision === 'exhausted' && rj.surviving[0].key === 'a');
313
+ ok('e2e: usage error exits 2', spawnSync(process.execPath, [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
314
+ const bad = setup('{nope', null);
315
+ ok('e2e: an invalid config exits 1', spawnSync(process.execPath, [scriptPath, 'ask', '--repo', bad.root], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: bad.gdir } }).status === 1);
316
+ bad.done(); s.done(); rm(bin); rm(dir);
317
+ }
318
+
319
+ console.log(`plan-review-rounds.test.js: ${passed} checks passed`);