@marcomigozzi/trailhead 0.17.0 → 0.18.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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -207,6 +207,87 @@ const EMPTY_DECISIONS = ['Intro line for the map, long enough.', '', '## Decisio
207
207
  ok('remove numbered: an all-same-number list is not renumbered', lazy.body === sec('1. b', '1. c'));
208
208
  }
209
209
 
210
+ // --- applyOp: facet stamp ----------------------------------------------------
211
+ {
212
+ const FACETS = [
213
+ '# Codebase',
214
+ '',
215
+ '## Layout',
216
+ '',
217
+ '_as of aaaaaaa_',
218
+ '',
219
+ '- `src/` the code',
220
+ '',
221
+ '## Stack',
222
+ '',
223
+ '- node',
224
+ '',
225
+ '## Empty',
226
+ '',
227
+ '## Pending',
228
+ '',
229
+ '_as of bbbbbbb_',
230
+ '',
231
+ '_none yet_',
232
+ '',
233
+ ].join('\n');
234
+ const STAMP = 'abc1234';
235
+ const stamped = (r) => r.body.split('\n');
236
+ const section = (body, name) => mod.facetsOf(body).find((f) => f.name === name);
237
+
238
+ const ins = applyOp(FACETS, { kind: 'append', section: 'Stack', line: '- more', stamp: STAMP });
239
+ ok('stamp: insert when the section has none', !ins.error && section(ins.body, 'Stack').sha === STAMP);
240
+ ok('stamp: inserted as first content, blank line after', /## Stack\n\n_as of abc1234_\n\n- node\n- more\n/.test(ins.body));
241
+ const rep = applyOp(FACETS, { kind: 'append', section: 'Layout', line: '- `lib/` helpers', stamp: STAMP });
242
+ ok('stamp: replaces the old stamp', section(rep.body, 'Layout').sha === STAMP && !rep.body.includes('aaaaaaa'));
243
+ ok('stamp: one stamp line per section', stamped(rep).filter((l) => /^_as of /.test(l)).length === 2);
244
+ const again = applyOp(rep.body, { kind: 'append', section: 'Layout', line: '- `x/` x', stamp: 'def5678' });
245
+ ok('stamp: repeat patch never duplicates the stamp', (again.body.match(/_as of def5678_/g) || []).length === 1 && !again.body.includes('abc1234_\n\n- `src/'));
246
+ ok('stamp: other sections untouched', section(rep.body, 'Stack').text === section(FACETS, 'Stack').text && section(rep.body, 'Pending').sha === 'bbbbbbb');
247
+ const keep = applyOp(FACETS, { kind: 'append', section: 'Layout', line: '- `lib/` helpers' });
248
+ ok('stamp: no --stamp keeps the old stamp', section(keep.body, 'Layout').sha === 'aaaaaaa' && (keep.body.match(/_as of aaaaaaa_/g) || []).length === 1);
249
+ const bare = applyOp(FACETS, { kind: 'append', section: 'Stack', line: '- more' });
250
+ ok('stamp: no old stamp and no --stamp leaves the section as before', bare.body === FACETS.replace('- node\n', '- node\n- more\n'));
251
+ const empty = applyOp(FACETS, { kind: 'append', section: 'Empty', line: '- first', stamp: STAMP });
252
+ ok('stamp: empty section gains stamp and line', section(empty.body, 'Empty').sha === STAMP && /## Empty\n\n_as of abc1234_\n\n- first\n/.test(empty.body));
253
+ const ph = applyOp(FACETS, { kind: 'append', section: 'Pending', line: '- real', stamp: STAMP });
254
+ ok('stamp: placeholder under an existing stamp is dropped', !/none yet/.test(ph.body) && section(ph.body, 'Pending').sha === STAMP && /_as of abc1234_\n\n- real\n/.test(ph.body));
255
+ const phKeep = applyOp(FACETS, { kind: 'append', section: 'Pending', line: '- real' });
256
+ ok('stamp: placeholder dropped and old stamp kept without --stamp', !/none yet/.test(phKeep.body) && section(phKeep.body, 'Pending').sha === 'bbbbbbb');
257
+
258
+ const r1 = applyOp(FACETS, { kind: 'replace', section: 'Layout', text: '- `new/` fresh\n', stamp: STAMP });
259
+ ok('stamp: replace writes the new content under the supplied stamp', section(r1.body, 'Layout').sha === STAMP && /_as of abc1234_\n\n- `new\/` fresh\n/.test(r1.body) && !r1.body.includes('src/'));
260
+ const r2 = applyOp(FACETS, { kind: 'replace', section: 'Layout', text: '_as of 1111111_\n\n- `new/` fresh\n', stamp: STAMP });
261
+ ok('stamp: replace file carrying its own stamp does not duplicate it', (r2.body.match(/_as of /g) || []).length === 2);
262
+ ok('stamp: replace file stamp is overridden by --stamp', section(r2.body, 'Layout').sha === STAMP && !r2.body.includes('1111111'));
263
+ const r3 = applyOp(FACETS, { kind: 'replace', section: 'Layout', text: '- `new/` fresh\n' });
264
+ ok('stamp: replace without --stamp keeps the old stamp', section(r3.body, 'Layout').sha === 'aaaaaaa');
265
+
266
+ const rm = applyOp(FACETS.replace('- node\n', '- node\n- go\n'), { kind: 'remove', section: 'Stack', match: '- go', stamp: STAMP });
267
+ ok('stamp: remove stamps the section', !rm.error && rm.changed && section(rm.body, 'Stack').sha === STAMP && !rm.body.includes('- go'));
268
+ const noop = applyOp(FACETS, { kind: 'remove', section: 'Stack', match: 'absent', stamp: STAMP });
269
+ ok('stamp: a no-op remove writes nothing and does not stamp', noop.changed === false && noop.body === FACETS);
270
+
271
+ const bad = applyOp(FACETS, { kind: 'append', section: 'Stack', line: 'x', stamp: 'XYZ' });
272
+ ok('stamp: invalid value is an error', !!bad.error);
273
+ const pre = applyOp(FACETS, { kind: 'append', section: ':preamble', line: 'x', stamp: STAMP });
274
+ ok('stamp: preamble is an error', !!pre.error);
275
+
276
+ const fenced = ['# C', '', '## Real', '', '_as of aaaaaaa_', '', '```', '## Not a facet', '```', ''].join('\n');
277
+ const fs2 = mod.facetsOf(fenced);
278
+ ok('facetsOf: fence-aware, one facet with its sha', fs2.length === 1 && fs2[0].name === 'Real' && fs2[0].sha === 'aaaaaaa');
279
+ const fl = mod.facetsOf(FACETS);
280
+ ok('facetsOf: names, shas and null for unstamped', fl.map((f) => f.name).join() === 'Layout,Stack,Empty,Pending' && fl[1].sha === null && fl[0].sha === 'aaaaaaa');
281
+
282
+ for (const stamp of ['XYZ', 'ABCDEF1', 'abc12', 'a'.repeat(41), '']) {
283
+ const r = mod.parseArgs(['append', '--issue', '1', '--section', 'x', '--line', 'y', '--stamp', stamp]);
284
+ ok(`stamp: parseArgs rejects "${stamp}"`, !!r.error);
285
+ }
286
+ ok('stamp: parseArgs rejects :preamble', !!mod.parseArgs(['append', '--issue', '1', '--section', ':preamble', '--line', 'y', '--stamp', 'abc1234']).error);
287
+ ok('stamp: parseArgs accepts 7 to 40 hex', mod.parseArgs(['append', '--issue', '1', '--section', 'x', '--line', 'y', '--stamp', 'abc1234']).stamp === 'abc1234'
288
+ && mod.parseArgs(['remove', '--issue', '1', '--section', 'x', '--match', 'y', '--stamp', 'a'.repeat(40)]).stamp === 'a'.repeat(40));
289
+ }
290
+
210
291
  // --- fake tracker ------------------------------------------------------------
211
292
  function makeTracker(init = {}) {
212
293
  const t = {
@@ -134,8 +134,52 @@ for (const [failOn, later] of [['ensure', ['view', 'comment', 'unassign', 'label
134
134
  ok('clear errors on unparseable JSON', run(makeTracker({ badJson: true }), clear).code === 1);
135
135
  }
136
136
 
137
+ // --- sweep ----------------------------------------------------------------------
138
+ // Tracker con più issue: closed [numeri chiusi con la label], failList, failRemove [numeri].
139
+ function makeSweepTracker(init = {}) {
140
+ const t = { closed: [...(init.closed || [])], failList: !!init.failList, badJson: !!init.badJson, failRemove: init.failRemove || [], calls: [] };
141
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
142
+ t.gh = (args) => {
143
+ t.calls.push(args.join(' '));
144
+ const [a, b] = args;
145
+ if (a === 'issue' && b === 'list') {
146
+ if (t.failList) return res('', 1);
147
+ return t.badJson ? res('not json') : res(JSON.stringify(t.closed.map((number) => ({ number }))));
148
+ }
149
+ if (a === 'issue' && b === 'edit' && args.includes('--remove-label')) {
150
+ const n = Number(args[2]);
151
+ if (t.failRemove.includes(n)) return res('', 1);
152
+ t.closed = t.closed.filter((x) => x !== n);
153
+ return res('');
154
+ }
155
+ return res('', 1);
156
+ };
157
+ return t;
158
+ }
159
+ const sweep = ['sweep', '--repo', 'o/r'];
160
+ {
161
+ const t = makeSweepTracker({ closed: [85, 12] });
162
+ const r = run(t, sweep);
163
+ ok('sweep ok, one JSON line', r.code === 0 && r.json.status === 'ok' && r.out.trim().split('\n').length === 1);
164
+ ok('sweep clears every closed labelled ticket', JSON.stringify(r.json.cleared) === '[12,85]' && t.closed.length === 0);
165
+ ok('sweep lists only closed tickets with the label', /^issue list --state closed --label trailhead:needs-you --json number --limit \d+ -R o\/r$/.test(t.calls[0]));
166
+ ok('sweep passes -R on every call', t.calls.every((c) => c.includes('-R o/r')));
167
+ const r2 = run(t, sweep);
168
+ ok('sweep is a no-op when nothing is left', r2.code === 0 && r2.json.cleared.length === 0 && t.calls.filter((c) => /--remove-label/.test(c)).length === 2);
169
+ }
170
+ {
171
+ const t = makeSweepTracker({ failList: true });
172
+ const r = run(t, sweep);
173
+ ok('sweep errors on a failed list, writes nothing', r.code === 1 && r.json.status === 'error' && !t.calls.some((c) => /issue edit/.test(c)));
174
+ ok('sweep errors on unparseable JSON', run(makeSweepTracker({ badJson: true }), sweep).code === 1);
175
+ const tp = makeSweepTracker({ closed: [3, 4, 5], failRemove: [4] });
176
+ const rp = run(tp, sweep);
177
+ ok('a failed removal is an error naming it, the others still cleared', rp.code === 1 && rp.json.status === 'error' && /#4/.test(rp.json.reason) && JSON.stringify(rp.json.cleared) === '[3,5]');
178
+ }
179
+
137
180
  // --- usage ----------------------------------------------------------------------
138
- for (const argv of [[], ['bogus'], ['park'], ['park', '--issue', '7', '--repo', 'o/r'], ['park', '--issue', 'x', '--reason', 'r', '--repo', 'o/r'],
181
+ for (const argv of [['sweep'], ['sweep', '--repo', 'nope'], ['sweep', '--repo', 'o/r', '--issue', '7'],
182
+ [], ['bogus'], ['park'], ['park', '--issue', '7', '--repo', 'o/r'], ['park', '--issue', 'x', '--reason', 'r', '--repo', 'o/r'],
139
183
  ['park', '--issue', '7', '--reason', ' ', '--repo', 'o/r'], ['park', '--issue', '7', '--reason', 'r'], ['clear', '--issue', '7'], ['clear', '--repo', 'o/r'],
140
184
  ['clear', '--issue', '7', '--repo', 'nope'], ['clear', '--issue', '7', '--repo', 'o/r', '--reason', 'r'], ['clear', '--issue']]) {
141
185
  const t = makeTracker();
@@ -7,6 +7,8 @@
7
7
  // 2. round: converged / continue / exhausted / no-progress / ceiling and their precedence.
8
8
  // 3. Usage errors (exit 2).
9
9
  // 4. End-to-end: the script itself through spawnSync.
10
+ // 5. budget (pre-check of a reviewer prompt) and overflow (post-check of its output). #353.
11
+ // 6. run (a reviewer call under a wall-clock deadline), real stub scripts through spawnSync. #368.
10
12
  const assert = require('assert');
11
13
  const { spawnSync } = require('child_process');
12
14
  const path = require('path');
@@ -316,4 +318,274 @@ ok('round: output is one status ok JSON line with the round count', round([[c('a
316
318
  bad.done(); s.done(); rm(bin); rm(dir);
317
319
  }
318
320
 
321
+ // --- budget ------------------------------------------------------------------
322
+ // A prompt file of exactly n bytes (ASCII), inside its own temp dir.
323
+ function promptFile(n, name = 'brief.md') {
324
+ const dir = tmp();
325
+ const file = path.join(dir, name);
326
+ fs.writeFileSync(file, 'x'.repeat(n));
327
+ return { dir, file };
328
+ }
329
+ function budget(s, reviewer, n, extra = {}, name) {
330
+ const p = promptFile(n, name);
331
+ const r = run(['budget', '--repo', s.root, '--reviewer', reviewer, '--prompt', p.file], { env: { CLAUDE_CONFIG_DIR: s.gdir, PATH: '/nowhere' }, ...extra });
332
+ rm(p.dir);
333
+ return { ...r, file: p.file };
334
+ }
335
+ {
336
+ const s = setup({}, null);
337
+ const r = budget(s, 'claude', 1000);
338
+ ok('budget: a small prompt is arg, exit 0, one JSON line', r.code === 0 && r.json.status === 'ok' && r.json.decision === 'arg' && r.out.trim().split('\n').length === 1);
339
+ ok('budget: reports reviewer, bytes, tokens, window, cap, windowSource', r.json.reviewer === 'claude' && r.json.bytes === 1000 && r.json.tokens === 334 && r.json.window === 200000 && r.json.cap === 130000 && r.json.windowSource === 'default');
340
+ ok('budget: arg carries no reason and no pointer, and warnings is empty', !('reason' in r.json) && !('pointer' in r.json) && Array.isArray(r.json.warnings) && r.json.warnings.length === 0);
341
+ ok('budget: bytes === 102400 is still arg', budget(s, 'claude', 102400).json.decision === 'arg');
342
+ ok('budget: bytes 102401 is file', budget(s, 'claude', 102401).json.decision === 'file');
343
+ const atCap = budget(s, 'claude', 390000);
344
+ ok('budget: tokens === cap is not skipped (file for a large prompt)', atCap.json.tokens === 130000 && atCap.json.decision === 'file');
345
+ const over = budget(s, 'claude', 390001);
346
+ ok('budget: tokens === cap + 1 is skip', over.json.tokens === 130001 && over.json.decision === 'skip');
347
+ ok('budget: skip carries a reason naming estimated tokens, cap and window', /130001/.test(over.json.reason) && /130000/.test(over.json.reason) && /200000/.test(over.json.reason) && !('pointer' in over.json));
348
+ ok('budget: skip wins over file', over.json.bytes > 102400 && over.json.decision === 'skip');
349
+ const f = budget(s, 'gemini', 200000);
350
+ ok('budget: file carries the pointer to the absolute prompt path', f.json.decision === 'file' && f.json.pointer === 'Read the complete review brief in ' + f.file + ' and answer it exactly as it asks.');
351
+ const sp = budget(s, 'gemini', 200000, {}, 'my brief.md');
352
+ ok('budget: a pointer keeps a path with spaces whole', sp.file.includes(' ') && sp.json.pointer === 'Read the complete review brief in ' + sp.file + ' and answer it exactly as it asks.');
353
+ const windows = { claude: 200000, gemini: 1000000, codex: 272000, qwen: 256000, kimi: 256000, agy: 1000000, opencode: 128000, 'cursor-agent': 128000, coderabbit: 128000 };
354
+ for (const [name, w] of Object.entries(windows)) {
355
+ const r2 = budget(s, name, 10);
356
+ ok('budget: default window for ' + name + ' is ' + w, r2.json.window === w && r2.json.cap === Math.floor(w * 0.65));
357
+ }
358
+ const custom = budget(s, 'my-local', 10);
359
+ ok('budget: an unknown reviewer gets the 128000 fallback window', custom.json.window === 128000 && custom.json.windowSource === 'default');
360
+ s.done();
361
+ }
362
+ {
363
+ const s = setup({ 'plan_review.context': { codex: 1000 } }, { 'plan_review.context': { gemini: 5000, codex: 2000 } });
364
+ const c1 = budget(s, 'codex', 10);
365
+ ok('budget: the project window wins for a CLI both layers name', c1.json.window === 1000 && c1.json.cap === 650 && c1.json.windowSource === 'config');
366
+ const g1 = budget(s, 'gemini', 10);
367
+ ok('budget: a CLI only the global layer names is honoured', g1.json.window === 5000 && g1.json.windowSource === 'config');
368
+ ok('budget: a CLI neither layer names keeps the default', budget(s, 'claude', 10).json.windowSource === 'default');
369
+ const sk = budget(s, 'codex', 2000);
370
+ ok('budget: a configured small window skips an oversized prompt', sk.json.tokens === 667 && sk.json.decision === 'skip');
371
+ s.done();
372
+ }
373
+ {
374
+ const s = setup({ 'plan_review.context': { codex: 0 } }, { 'plan_review.context': { codex: 2000 } });
375
+ const r = budget(s, 'codex', 10);
376
+ ok('budget: an invalid project entry warns and falls through to the global value', r.json.window === 2000 && r.json.windowSource === 'config' && r.json.warnings.some((w) => w.includes('plan_review.context') && w.includes('codex')));
377
+ s.done();
378
+ }
379
+ {
380
+ const s = setup({ 'plan_review.context': 'big' }, null);
381
+ const r = budget(s, 'codex', 10);
382
+ ok('budget: a non-object plan_review.context warns and is ignored', r.code === 0 && r.json.window === 272000 && r.json.windowSource === 'default' && r.json.warnings.some((w) => w.includes('plan_review.context')));
383
+ s.done();
384
+ const arr = setup({ 'plan_review.context': [1] }, null);
385
+ ok('budget: an array plan_review.context is not an object, warned', budget(arr, 'codex', 10).json.warnings.length === 1);
386
+ arr.done();
387
+ }
388
+ for (const bad of [0, -5, 1.5, '1000', 10000001, null]) {
389
+ const s = setup({ 'plan_review.context': { codex: bad } }, null);
390
+ const r = budget(s, 'codex', 10);
391
+ ok('budget: context entry ' + JSON.stringify(bad) + ' is rejected with a warning', r.code === 0 && r.json.window === 272000 && r.json.windowSource === 'default' && r.json.warnings.length === 1);
392
+ s.done();
393
+ }
394
+ {
395
+ const s = setup({ 'plan_review.context': { codex: 10000000 } }, null);
396
+ ok('budget: 10000000 is the largest accepted window', budget(s, 'codex', 10).json.window === 10000000);
397
+ s.done();
398
+ const multi = setup({ 'plan_review.context': { codex: 4000 } }, null);
399
+ ok('budget: bytes are counted as UTF-8, not characters', (() => {
400
+ const dir = tmp();
401
+ const file = path.join(dir, 'u.md');
402
+ fs.writeFileSync(file, 'é'.repeat(10));
403
+ const r = run(['budget', '--repo', multi.root, '--reviewer', 'codex', '--prompt', file], { env: { CLAUDE_CONFIG_DIR: multi.gdir, PATH: '/nowhere' } });
404
+ rm(dir);
405
+ return r.json.bytes === 20 && r.json.tokens === 7;
406
+ })());
407
+ multi.done();
408
+ }
409
+ {
410
+ const s = setup({}, null);
411
+ const env = { env: { CLAUDE_CONFIG_DIR: s.gdir, PATH: '/nowhere' } };
412
+ ok('usage: budget without --repo exits 2', run(['budget', '--reviewer', 'codex', '--prompt', '/x'], env).code === 2);
413
+ ok('usage: budget without --reviewer exits 2', run(['budget', '--repo', s.root, '--prompt', '/x'], env).code === 2);
414
+ ok('usage: budget without --prompt exits 2', run(['budget', '--repo', s.root, '--reviewer', 'codex'], env).code === 2);
415
+ const u = run(['budget', '--repo', s.root], env);
416
+ ok('usage: budget errors go to stderr, stdout stays empty', u.errOut.startsWith('usage:') && u.out === '');
417
+ const missing = run(['budget', '--repo', s.root, '--reviewer', 'codex', '--prompt', '/nowhere/brief.md'], env);
418
+ ok('budget: an unreadable prompt file is status error, exit 1', missing.code === 1 && missing.json.status === 'error' && /cannot read/.test(missing.json.reason));
419
+ const bad = setup('{nope', null);
420
+ const p = promptFile(10);
421
+ const e = run(['budget', '--repo', bad.root, '--reviewer', 'codex', '--prompt', p.file], { env: { CLAUDE_CONFIG_DIR: bad.gdir, PATH: '/nowhere' } });
422
+ ok('budget: an invalid config is status error, exit 1', e.code === 1 && e.json.status === 'error');
423
+ rm(p.dir); bad.done(); s.done();
424
+ }
425
+
426
+ // --- overflow ----------------------------------------------------------------
427
+ function overflow(text, brief) {
428
+ const dir = tmp();
429
+ const file = path.join(dir, 'codex.out');
430
+ fs.writeFileSync(file, text);
431
+ const extra = [];
432
+ if (brief !== undefined) {
433
+ const pf = path.join(dir, 'brief.md');
434
+ fs.writeFileSync(pf, brief);
435
+ extra.push('--prompt', pf);
436
+ }
437
+ const r = run(['overflow', '--output', file, ...extra]);
438
+ rm(dir);
439
+ return r;
440
+ }
441
+ {
442
+ const msgs = ['context_length_exceeded', 'This model\'s Maximum Context Length is 128000 tokens', 'Error: prompt is too long: 250000 tokens', 'input exceeds the context window of the model', 'Input is too long for requested model', 'The input token count (300000) exceeds the maximum number of tokens allowed (128000)', 'spawn E2BIG: Argument list too long'];
443
+ for (const m of msgs) {
444
+ const r = overflow('some output\n ' + m + ' \nmore');
445
+ ok('overflow: matches "' + m + '"', r.code === 0 && r.json.status === 'ok' && r.json.overflow === true && r.json.reason.includes(m) && r.json.reason === r.json.reason.trim());
446
+ }
447
+ ok('overflow: matching is case-insensitive', overflow('PROMPT IS TOO LONG').json.overflow === true);
448
+ ok('overflow: the quoted reason is length-capped', overflow('prompt is too long ' + 'y'.repeat(5000)).json.reason.length <= 300);
449
+ ok('overflow: ordinary output is not an overflow', (() => { const r = overflow('1. Risk: the plan is too long to read\nblocking: none'); return r.code === 0 && r.json.overflow === false && !('reason' in r.json); })());
450
+ ok('overflow: a timeout is not an overflow', overflow('Error: request timed out after 540s').json.overflow === false);
451
+ ok('overflow: an auth failure is not an overflow', overflow('401 Unauthorized: invalid API key').json.overflow === false);
452
+ ok('overflow: a rate-limit error is not an overflow', overflow('429 Too Many Requests: rate limit reached, too many tokens per minute').json.overflow === false);
453
+ ok('overflow: a line of the brief echoed back is not an overflow', overflow('user\n- prompt is too long -> skip\nError: request timed out', 'The PLAN:\n- prompt is too long -> skip\n').json.overflow === false);
454
+ ok('overflow: a real error still matches with the brief passed', overflow('- prompt is too long -> skip\nError: prompt is too long: 250000 tokens', '- prompt is too long -> skip\n').json.overflow === true);
455
+ ok('overflow: an unreadable brief file is status error, exit 1', (() => { const d = tmp(); const f = path.join(d, 'x.out'); fs.writeFileSync(f, 'prompt is too long'); const r = run(['overflow', '--output', f, '--prompt', path.join(d, 'none.md')]); rm(d); return r.code === 1 && r.json.status === 'error'; })());
456
+ ok('overflow: an empty output file is not an overflow', overflow('').json.overflow === false);
457
+ ok('overflow: the helper\'s own spawn E2BIG launch error is an overflow', overflow('trailhead run: cannot launch codex: spawn E2BIG\n').json.overflow === true);
458
+ ok('overflow: an E2BIG line in the stderr part of a combined file is caught', overflow('partial stdout review text\n--- stderr ---\nbash: /usr/bin/codex: Argument list too long\n').json.overflow === true);
459
+ ok('usage: overflow without --output exits 2', run(['overflow']).code === 2);
460
+ const miss = run(['overflow', '--output', '/nowhere/x.out']);
461
+ ok('overflow: an unreadable output file is status error, exit 1', miss.code === 1 && miss.json.status === 'error');
462
+ }
463
+
464
+ // --- budget and overflow end-to-end ------------------------------------------
465
+ {
466
+ const s = setup({}, null);
467
+ const p = promptFile(200000, 'brief.md');
468
+ const res = spawnSync(process.execPath, [scriptPath, 'budget', '--repo', s.root, '--reviewer', 'gemini', '--prompt', p.file], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: s.gdir } });
469
+ const j = JSON.parse(res.stdout.trim());
470
+ ok('e2e: budget prints one JSON line with the file decision and exits 0', res.status === 0 && j.decision === 'file' && j.pointer.includes(p.file));
471
+ const huge = promptFile(500000);
472
+ const sk = spawnSync(process.execPath, [scriptPath, 'budget', '--repo', s.root, '--reviewer', 'claude', '--prompt', huge.file], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: s.gdir } });
473
+ ok('e2e: budget skip is still exit 0 with a reason', sk.status === 0 && JSON.parse(sk.stdout.trim()).decision === 'skip');
474
+ ok('e2e: budget usage error exits 2', spawnSync(process.execPath, [scriptPath, 'budget', '--repo', s.root], { encoding: 'utf8' }).status === 2);
475
+ const out = path.join(p.dir, 'o.out');
476
+ fs.writeFileSync(out, 'oops\ncontext_length_exceeded\n');
477
+ const ov = spawnSync(process.execPath, [scriptPath, 'overflow', '--output', out], { encoding: 'utf8' });
478
+ const oj = JSON.parse(ov.stdout.trim());
479
+ ok('e2e: overflow prints the verdict and exits 0', ov.status === 0 && oj.overflow === true && oj.reason.includes('context_length_exceeded'));
480
+ fs.writeFileSync(out, 'all good');
481
+ ok('e2e: overflow false on ordinary output', JSON.parse(spawnSync(process.execPath, [scriptPath, 'overflow', '--output', out], { encoding: 'utf8' }).stdout.trim()).overflow === false);
482
+ rm(p.dir); rm(huge.dir); s.done();
483
+ }
484
+
485
+ // --- run: a reviewer call under a deadline (#368) -------------------------------
486
+ {
487
+ const dir = tmp();
488
+ const out = path.join(dir, 'r.out');
489
+ const stub = (name, body) => { const f = path.join(dir, name); fs.writeFileSync(f, body); return f; };
490
+ const helper = (extra, cmd, env = {}) => {
491
+ const t0 = Date.now();
492
+ const res = spawnSync(process.execPath, [scriptPath, 'run', '--output', out, ...extra, '--', ...cmd], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 30000 });
493
+ let json = null;
494
+ try { json = JSON.parse(res.stdout.trim()); } catch { /* not json */ }
495
+ return { res, json, ms: Date.now() - t0, text: fs.existsSync(out) ? fs.readFileSync(out, 'utf8') : null };
496
+ };
497
+ const node = (f, ...a) => [process.execPath, f, ...a];
498
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
499
+ const sleepMs = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
500
+
501
+ const fast = helper([], node(stub('fast.js', "console.log('review text'); console.error('warn text');")));
502
+ ok('run: fast exit 0 is status ok, exit 0, one JSON line', fast.res.status === 0 && fast.json.status === 'ok' && fast.json.exitCode === 0 && fast.json.signal === null && fast.json.timeoutSeconds === 600 && fast.json.output === out && !('reason' in fast.json) && fast.res.stdout.trim().split('\n').length === 1);
503
+ ok('run: stdout and stderr both land in the output file', fast.text.includes('review text') && fast.text.includes('warn text'));
504
+
505
+ const bad = helper([], node(stub('bad.js', "console.log('partial'); process.exit(3);")));
506
+ ok('run: a non-zero exit is failed with its code, still exit 0', bad.res.status === 0 && bad.json.status === 'failed' && bad.json.exitCode === 3 && /3/.test(bad.json.reason) && bad.text.includes('partial'));
507
+
508
+ const stdinStub = stub('stdin.js', "process.stdin.on('data', () => {}); process.stdin.on('end', () => { console.log('EOF'); });");
509
+ const eof = helper(['--timeout', '5'], node(stdinStub));
510
+ ok('run: stdin is closed (a reader gets EOF at once)', eof.json.status === 'ok' && eof.text.includes('EOF') && eof.ms < 4000);
511
+
512
+ const missing = helper([], [path.join(dir, 'no-such-cli'), 'x']);
513
+ ok('run: a missing command is status error, exit 0, text in the file', missing.res.status === 0 && missing.json.status === 'error' && missing.json.exitCode === null && /ENOENT/.test(missing.json.reason) && /ENOENT/.test(missing.text));
514
+ const noexec = helper([], [stub('plain.txt', 'x')]);
515
+ ok('run: a non-executable command is status error', noexec.res.status === 0 && noexec.json.status === 'error' && /EACCES/.test(noexec.text));
516
+
517
+ const pass = helper([], node(stub('argv.js', "console.log(JSON.stringify(process.argv.slice(2)));"), '--foo', '', '--timeout', 'a b'));
518
+ ok('run: argv after -- is passed verbatim, flags and empty strings included', pass.json.status === 'ok' && pass.text.trim() === JSON.stringify(['--foo', '', '--timeout', 'a b']));
519
+
520
+ const sleeper = helper(['--timeout', '1'], node(stub('sleep.js', "console.log('Reading additional input from stdin...'); setTimeout(() => {}, 300000);")));
521
+ ok('run: a silent sleeper is timeout within the bound, exit 0', sleeper.res.status === 0 && sleeper.json.status === 'timeout' && sleeper.json.timeoutSeconds === 1 && /1s/.test(sleeper.json.reason) && sleeper.ms < 6000);
522
+ ok('run: the partial output of a timed-out call stays in the file', sleeper.text.includes('Reading additional input from stdin...'));
523
+ const ov = run(['overflow', '--output', out]);
524
+ ok('run: overflow on that partial output is not what classifies a timeout', ov.json.overflow === false && sleeper.json.status === 'timeout');
525
+ const ovText = stub('ov.js', "console.log('context_length_exceeded'); setTimeout(() => {}, 300000);");
526
+ const slow = helper(['--timeout', '1'], node(ovText));
527
+ ok('run: timeout wins even when the output looks like an overflow', slow.json.status === 'timeout' && run(['overflow', '--output', out]).json.overflow === true);
528
+
529
+ const pidFile = path.join(dir, 'gc.pid');
530
+ const grand = stub('grand.js', `
531
+ const { spawn } = require('child_process');
532
+ const fs = require('fs');
533
+ process.on('SIGTERM', () => {});
534
+ const gc = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setTimeout(() => {}, 300000);"], { stdio: 'ignore' });
535
+ fs.writeFileSync(${JSON.stringify(pidFile)}, String(gc.pid));
536
+ setTimeout(() => {}, 300000);
537
+ `);
538
+ const stubborn = helper(['--timeout', '1'], node(grand), { TRAILHEAD_REVIEW_GRACE_MS: '400' });
539
+ ok('run: a child ignoring SIGTERM still ends as timeout, killed by SIGKILL', stubborn.json.status === 'timeout' && stubborn.json.signal === 'SIGKILL' && stubborn.ms < 8000);
540
+ const gcPid = Number(fs.readFileSync(pidFile, 'utf8'));
541
+ for (let i = 0; i < 20 && alive(gcPid); i++) sleepMs(100);
542
+ ok('run: the grandchild does not survive the timeout', gcPid > 0 && !alive(gcPid));
543
+
544
+ // Signals sent to the helper kill the reviewer's group: SIGHUP 129, SIGTERM 143, SIGINT 130.
545
+ // A driver process signals the helper once the sleeper has written its pid, then reports.
546
+ const sigPid = path.join(dir, 'sig.pid');
547
+ const sigStub = stub('sigsleep.js', `require('fs').writeFileSync(${JSON.stringify(sigPid)}, String(process.pid)); setTimeout(() => {}, 300000);`);
548
+ const driver = stub('sigdriver.js', `
549
+ const { spawn } = require('child_process');
550
+ const fs = require('fs');
551
+ const [script, output, pidFile, stubFile, sig] = process.argv.slice(2);
552
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
553
+ const h = spawn(process.execPath, [script, 'run', '--output', output, '--timeout', '60', '--', process.execPath, stubFile], { stdio: 'ignore' });
554
+ let code = null;
555
+ h.on('exit', (c) => { code = c; });
556
+ let n = 0;
557
+ const wait = setInterval(() => {
558
+ if (!fs.existsSync(pidFile) || ++n > 100) { if (n > 100) { clearInterval(wait); console.log('{}'); h.kill('SIGKILL'); } return; }
559
+ clearInterval(wait);
560
+ const pid = Number(fs.readFileSync(pidFile, 'utf8'));
561
+ h.kill(sig);
562
+ let m = 0;
563
+ const settle = setInterval(() => {
564
+ if ((code !== null && !alive(pid)) || ++m > 50) {
565
+ clearInterval(settle);
566
+ const dead = !alive(pid);
567
+ if (!dead) process.kill(pid, 'SIGKILL');
568
+ console.log(JSON.stringify({ code, dead }));
569
+ }
570
+ }, 100);
571
+ }, 100);
572
+ `);
573
+ for (const [sig, code] of [['SIGHUP', 129], ['SIGTERM', 143], ['SIGINT', 130]]) {
574
+ try { fs.unlinkSync(sigPid); } catch { /* none yet */ }
575
+ const r = spawnSync(process.execPath, [driver, scriptPath, out, sigPid, sigStub, sig], { encoding: 'utf8', timeout: 30000 });
576
+ let d = {};
577
+ try { d = JSON.parse(r.stdout.trim()); } catch { /* not json */ }
578
+ ok(`run: ${sig} to the helper kills the reviewer and exits ${code}`, d.code === code && d.dead === true);
579
+ }
580
+
581
+ const usage = (argv) => spawnSync(process.execPath, [scriptPath, 'run', ...argv], { encoding: 'utf8' });
582
+ ok('usage: run without --output exits 2', usage(['--', 'true']).status === 2);
583
+ ok('usage: run without a command exits 2', usage(['--output', out]).status === 2 && usage(['--output', out, '--']).status === 2);
584
+ ok('usage: run with a bad --timeout exits 2', ['0', '-1', '1.5', 'abc'].every((v) => usage(['--output', out, '--timeout', v, '--', 'true']).status === 2));
585
+ ok('usage: run with an unknown helper flag exits 2', usage(['--output', out, '--bogus', 'x', '--', 'true']).status === 2);
586
+ const unwritable = spawnSync(process.execPath, [scriptPath, 'run', '--output', path.join(dir, 'no', 'such', 'dir', 'x.out'), '--', 'true'], { encoding: 'utf8' });
587
+ ok('run: an unwritable output file exits 1 with status error', unwritable.status === 1 && JSON.parse(unwritable.stdout.trim()).status === 'error');
588
+ rm(dir);
589
+ }
590
+
319
591
  console.log(`plan-review-rounds.test.js: ${passed} checks passed`);