@marcomigozzi/trailhead 0.16.1 → 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 (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  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 +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // Test per model-route.js (#323). Run: node model-route.test.js
3
+ // Niente framework: assert semplici, stesso stile degli altri test di hooks/lib.
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const LIB = path.join(__dirname, 'model-route.js');
11
+ const root = path.resolve(__dirname, '..', '..', '..', '..');
12
+ const { route, AGENT_OF, BALANCED, familyAlias, loadTierIds } = require(LIB);
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+ const throws = (fn) => { try { fn(); } catch (e) { return true; } return false; };
17
+ const TIERS = loadTierIds('claude');
18
+ const OPUS = 'claude-opus-5-5';
19
+ const SONNET = 'claude-sonnet-5-5';
20
+
21
+ const claude = (extra) => route({ key: 'grill', host: 'claude', models: {}, sessionModel: SONNET, subagents: true, tierIds: TIERS, ...extra });
22
+ const codex = (extra) => route({ key: 'grill', host: 'codex', models: {}, sessionModel: 'gpt-5.6-terra', subagents: true, tierIds: TIERS, ...extra });
23
+
24
+ // --- Claude --------------------------------------------------------------------
25
+ {
26
+ const r = claude({});
27
+ ok('claude: sonnet session, key unset -> dispatch trailhead-grill on opus',
28
+ r.route === 'dispatch' && r.agent === 'trailhead-grill' && r.model === OPUS && r.alias === 'opus');
29
+ ok('claude: strong comes from the current model-defaults entry', TIERS.strong === OPUS);
30
+ ok('claude: opus session, key unset -> inline', claude({ sessionModel: OPUS }).route === 'inline');
31
+ ok('claude: sonnet pin in a sonnet session -> inline', claude({ models: { grill: SONNET } }).route === 'inline');
32
+ ok('claude: inherit session -> inline', claude({ models: { grill: 'inherit session' } }).route === 'inline');
33
+ for (const unset of [null, '', undefined]) {
34
+ ok(`claude: ${JSON.stringify(unset)} resolves like an unset key`, claude({ models: { grill: unset } }).route === 'dispatch');
35
+ }
36
+ const pinned = claude({ sessionModel: OPUS, models: { grill: 'claude-opus-5' } });
37
+ ok('claude: a different opus version dispatches with the opus alias', pinned.route === 'dispatch' && pinned.alias === 'opus' && pinned.model === 'claude-opus-5');
38
+ ok('claude: a suffixed opus session + unset key -> inline', claude({ sessionModel: 'claude-opus-5-5[1m]' }).route === 'inline');
39
+ const sfx = claude({ sessionModel: 'claude-sonnet-5-5[1m]' });
40
+ ok('claude: a suffixed sonnet session + unset key -> dispatch opus', sfx.route === 'dispatch' && sfx.model === OPUS && sfx.alias === 'opus');
41
+ ok('claude: a suffixed configured id equals the plain session id', claude({ sessionModel: SONNET, models: { grill: `${SONNET}[1m]` } }).route === 'inline');
42
+ ok('claude: no subagents, suffixed session already on the model -> no suggestion', !claude({ subagents: false, sessionModel: 'claude-opus-5-5[1m]' }).suggest);
43
+ const unk = claude({ models: { grill: 'my-custom-model' } });
44
+ ok('claude: an unknown id that differs from the session -> inline with warning', unk.route === 'inline' && unk.warning === 'unknown-id');
45
+ const unkEq = claude({ sessionModel: 'my-custom-model', models: { grill: 'my-custom-model' } });
46
+ ok('claude: an unknown id equal to the session -> inline, no warning', unkEq.route === 'inline' && !unkEq.warning);
47
+ const unknownSession = claude({ sessionModel: 'unknown' });
48
+ ok('claude: session unknown, key unset -> dispatch', unknownSession.route === 'dispatch');
49
+ ok('claude: a pin literally named unknown never equals the reserved value',
50
+ claude({ sessionModel: 'unknown', models: { grill: 'unknown' } }).route === 'inline' && claude({ sessionModel: 'unknown', models: { grill: 'unknown' } }).warning === 'unknown-id');
51
+ const ns = claude({ subagents: false });
52
+ ok('claude: no subagents -> inline with a suggestion of the resolved id', ns.route === 'inline' && ns.suggest === OPUS);
53
+ ok('claude: no subagents and already on that model -> no suggestion', !claude({ subagents: false, sessionModel: OPUS }).suggest);
54
+ ok('claude: no subagents and unknown session -> suggestion', claude({ subagents: false, sessionModel: 'unknown' }).suggest === OPUS);
55
+ ok('claude: alias families', familyAlias('claude-opus-5-5') === 'opus' && familyAlias('claude-sonnet-5') === 'sonnet' &&
56
+ familyAlias('claude-haiku-4-5-20251001') === 'haiku' && familyAlias('claude-fable-5') === 'fable' && familyAlias('gpt-5.6-sol') === null);
57
+ ok('claude: the config key for another activity uses its own Balanced tier',
58
+ route({ key: 'codebase-map', host: 'claude', models: {}, sessionModel: OPUS, subagents: true, tierIds: TIERS }).model === TIERS.fast);
59
+ ok('claude: models.codex.grill is ignored on the claude host',
60
+ claude({ models: { codex: { grill: 'gpt-5.6-sol' } } }).model === OPUS);
61
+ }
62
+
63
+ // --- #323: grill non impostato segue plan ---
64
+ {
65
+ const g = claude({ sessionModel: OPUS, models: { plan: SONNET } });
66
+ ok('grill unset follows plan: plan sonnet in an opus session -> dispatch sonnet',
67
+ g.route === 'dispatch' && g.agent === 'trailhead-grill' && g.model === SONNET && g.alias === 'sonnet');
68
+ const bal = claude({ sessionModel: SONNET, models: {} });
69
+ ok('grill unset + plan unset in a sonnet session -> dispatch opus (plan Balanced default)', bal.route === 'dispatch' && bal.model === OPUS && bal.alias === 'opus');
70
+ ok('grill unset + plan equal to the session -> inline', claude({ sessionModel: SONNET, models: { plan: SONNET } }).route === 'inline');
71
+ const own = claude({ sessionModel: OPUS, models: { plan: SONNET, grill: 'claude-haiku-5-5' } });
72
+ ok('grill set overrides plan', own.route === 'dispatch' && own.model === 'claude-haiku-5-5');
73
+ ok('plan inherit session + grill unset -> inline', claude({ models: { plan: 'inherit session' } }).route === 'inline');
74
+ ok('grill inherit session wins over plan', claude({ models: { plan: OPUS, grill: 'inherit session' } }).route === 'inline');
75
+ for (const unset of [null, '']) {
76
+ ok(`grill ${JSON.stringify(unset)} follows plan`, claude({ sessionModel: OPUS, models: { plan: SONNET, grill: unset } }).model === SONNET);
77
+ }
78
+ const old = { plan: SONNET, execute: SONNET, research: SONNET, review: SONNET, debug: SONNET, verify: SONNET, 'codebase-map': 'claude-haiku-5-5' };
79
+ const legacy = claude({ sessionModel: OPUS, models: old });
80
+ ok('a config with the seven old keys and no grill grills on the plan id', legacy.route === 'dispatch' && legacy.model === SONNET);
81
+ ok('other keys do not follow plan', route({ key: 'research', host: 'claude', models: { plan: SONNET }, sessionModel: SONNET, subagents: true, tierIds: TIERS }).route === 'inline');
82
+ const noSub = claude({ sessionModel: OPUS, subagents: false, models: { plan: SONNET } });
83
+ ok('no subagents: suggests the plan id', noSub.route === 'inline' && noSub.suggest === SONNET);
84
+ }
85
+
86
+ // --- Codex ---------------------------------------------------------------------
87
+ {
88
+ ok('codex: key unset -> inline', codex({}).route === 'inline');
89
+ ok('codex: inherit session -> inline', codex({ models: { codex: { grill: 'inherit session' } } }).route === 'inline');
90
+ ok('codex: models.grill (claude ids) is ignored', codex({ models: { grill: OPUS } }).route === 'inline');
91
+ const d = codex({ models: { codex: { grill: 'gpt-5.6-sol' } } });
92
+ ok('codex: a pin different from the session -> dispatch trailhead-grill', d.route === 'dispatch' && d.agent === 'trailhead-grill' && d.alias === null && d.model === 'gpt-5.6-sol');
93
+ ok('codex: a bare pin equal to the session -> inline', codex({ sessionModel: 'gpt-5.6-sol', models: { codex: { grill: 'gpt-5.6-sol' } } }).route === 'inline');
94
+ const pin = { model: 'gpt-5.6-sol', effort: 'high' };
95
+ const eq = { sessionModel: 'gpt-5.6-sol', models: { codex: { grill: pin } } };
96
+ ok('codex: equal model and equal effort -> inline', codex({ ...eq, sessionEffort: 'high' }).route === 'inline');
97
+ ok('codex: equal model, different effort -> dispatch', codex({ ...eq, sessionEffort: 'medium' }).route === 'dispatch');
98
+ ok('codex: equal model, effort pinned, session effort unknown -> dispatch', codex(eq).route === 'dispatch');
99
+ ok('codex: equal model, no pinned effort, any session effort -> inline',
100
+ codex({ sessionModel: 'gpt-5.6-sol', sessionEffort: 'low', models: { codex: { grill: { model: 'gpt-5.6-sol' } } } }).route === 'inline');
101
+ ok('codex: session unknown -> dispatch', codex({ sessionModel: 'unknown', models: { codex: { grill: 'gpt-5.6-sol' } } }).route === 'dispatch');
102
+ ok('codex: an object pin without a model stays inline', codex({ models: { codex: { grill: { effort: 'high' } } } }).route === 'inline');
103
+ ok('codex: execute dispatches the projected trailhead-execute',
104
+ route({ key: 'execute', host: 'codex', models: { codex: { execute: 'gpt-5.6-sol' } }, sessionModel: 'gpt-5.6-terra', subagents: true, tierIds: TIERS }).agent === 'trailhead-execute');
105
+ }
106
+
107
+ {
108
+ const cx = (extra) => codex({ ...extra });
109
+ const planPin = { codex: { plan: 'gpt-5.6-sol' } };
110
+ const d = cx({ models: planPin });
111
+ ok('codex: grill unset + codex.plan pinned -> dispatch trailhead-grill on the plan pin', d.route === 'dispatch' && d.agent === 'trailhead-grill' && d.model === 'gpt-5.6-sol');
112
+ ok('codex: grill unset + codex.plan equal to the session -> inline', cx({ sessionModel: 'gpt-5.6-sol', models: planPin }).route === 'inline');
113
+ ok('codex: plan pin with effort follows the same effort rules',
114
+ cx({ sessionModel: 'gpt-5.6-sol', models: { codex: { plan: { model: 'gpt-5.6-sol', effort: 'high' } } } }).route === 'dispatch' &&
115
+ cx({ sessionModel: 'gpt-5.6-sol', sessionEffort: 'high', models: { codex: { plan: { model: 'gpt-5.6-sol', effort: 'high' } } } }).route === 'inline');
116
+ ok('codex: grill set overrides plan', cx({ sessionModel: 'gpt-5.6-sol', models: { codex: { plan: 'gpt-5.6-terra', grill: 'gpt-5.6-sol' } } }).route === 'inline');
117
+ ok('codex: grill inherit session wins over plan', cx({ models: { codex: { plan: 'gpt-5.6-sol', grill: 'inherit session' } } }).route === 'inline');
118
+ for (const [label, pinv] of [['object form', { model: 'inherit session', effort: 'high' }], ['case variant', ' Inherit Session '], ['bare string', 'inherit session']]) {
119
+ ok(`codex: plan ${label} of inherit session + grill unset -> inline`, cx({ models: { codex: { plan: pinv } } }).route === 'inline');
120
+ ok(`codex: grill ${label} of inherit session wins over a plan pin -> inline`, cx({ models: { codex: { plan: 'gpt-5.6-sol', grill: pinv } } }).route === 'inline');
121
+ }
122
+ ok('claude: a case variant of inherit session routes inline', claude({ models: { grill: ' Inherit Session ' } }).route === 'inline' && claude({ models: { plan: 'INHERIT SESSION' } }).route === 'inline');
123
+ ok('codex: grill unset + plan unset -> inline', cx({ models: { codex: {} } }).route === 'inline');
124
+ }
125
+
126
+ // --- agent mapping -------------------------------------------------------------
127
+ {
128
+ const { MODEL_KEYS, codexAgentTomlPlan } = require(path.join(root, 'bin', 'lib', 'codex-projection.js'));
129
+ const keys = [...MODEL_KEYS].sort();
130
+ ok('agents: both tables cover exactly the MODEL_KEYS',
131
+ JSON.stringify(Object.keys(AGENT_OF.claude).sort()) === JSON.stringify(keys) &&
132
+ JSON.stringify(Object.keys(AGENT_OF.codex).sort()) === JSON.stringify(keys));
133
+ for (const [key, name] of Object.entries(AGENT_OF.claude)) {
134
+ ok(`agents: claude ${key} -> ${name} exists under agents/`, fs.existsSync(path.join(root, 'plugins', 'trailhead', 'agents', `${name}.md`)));
135
+ }
136
+ const defs = fs.readdirSync(path.join(root, 'plugins', 'trailhead', 'agents'))
137
+ .filter((f) => /^trailhead-.*\.md$/.test(f)).sort()
138
+ .map((f) => ({ name: f.slice(0, -3), description: 'd', tools: 'Read', body: 'b' }));
139
+ const models = Object.fromEntries(keys.map((k) => [k, `pin-${k}`]));
140
+ const writes = codexAgentTomlPlan('/c', models, defs).writes;
141
+ for (const [key, name] of Object.entries(AGENT_OF.codex)) {
142
+ const w = writes.find((x) => x.content.includes(`model = "pin-${key}"`));
143
+ ok(`agents: codex ${key} -> ${name} is the name the projection emits`, !!w && w.name === name);
144
+ }
145
+ ok('agents: execute and review map to the distinct per-host names',
146
+ AGENT_OF.claude.execute === 'trailhead-executor' && AGENT_OF.codex.execute === 'trailhead-execute' &&
147
+ AGENT_OF.claude.review === 'trailhead-code-review' && AGENT_OF.codex.review === 'trailhead-review');
148
+ ok('agents: an unknown key throws', throws(() => route({ key: 'nope', host: 'claude', models: {}, sessionModel: 'unknown', subagents: true, tierIds: TIERS })));
149
+ ok('agents: an unknown host throws', throws(() => route({ key: 'grill', host: 'mars', models: {}, sessionModel: 'unknown', subagents: true, tierIds: TIERS })));
150
+ }
151
+
152
+ // --- BALANCED pinned to the profile table -----------------------------------------
153
+ {
154
+ const ref = fs.readFileSync(path.join(root, 'plugins', 'trailhead', 'skills', '_shared', 'configuration-reference.md'), 'utf8').split('\n');
155
+ const cells = (l) => l.split('|').slice(1, -1).map((c) => c.trim());
156
+ const header = cells(ref.find((l) => l.startsWith('| profile |')));
157
+ const row = cells(ref.find((l) => l.startsWith('| **Balanced** |')));
158
+ const parsed = Object.fromEntries(header.slice(1).map((k, i) => [k, row[i + 1]]));
159
+ const { grill: tableGrill, ...tableRest } = parsed;
160
+ ok('BALANCED equals the Balanced row of the profile table, minus grill', JSON.stringify(BALANCED) === JSON.stringify(tableRest) && !('grill' in BALANCED));
161
+ ok('the grill column mirrors the plan column in every profile', ['High', 'Balanced', 'Low'].every((name) => {
162
+ const r = cells(ref.find((l) => l.startsWith(`| **${name}** |`)));
163
+ return r[header.indexOf('grill')] === r[header.indexOf('plan')];
164
+ }) && tableGrill === parsed.plan);
165
+ }
166
+
167
+ // --- CLI -----------------------------------------------------------------------
168
+ {
169
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-model-route-'));
170
+ const globalDir = path.join(tmp, 'claude');
171
+ fs.mkdirSync(path.join(globalDir, 'trailhead'), { recursive: true });
172
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { grill: 'claude-sonnet-5', plan: 'claude-haiku-5-5' } }));
173
+ const proj = path.join(tmp, 'proj');
174
+ fs.mkdirSync(path.join(proj, '.trailhead'), { recursive: true });
175
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { grill: 'claude-opus-5' } }));
176
+ const env = { ...process.env, CLAUDE_CONFIG_DIR: globalDir, CODEX_HOME: path.join(tmp, 'codex') };
177
+ const run = (args) => spawnSync(process.execPath, [LIB, ...args], { encoding: 'utf8', env });
178
+ const r = run(['grill', '--session', SONNET, proj]);
179
+ const j = JSON.parse(r.stdout);
180
+ ok('cli: exit 0 and one JSON line with the six keys',
181
+ r.status === 0 && r.stdout.trim().split('\n').length === 1 &&
182
+ JSON.stringify(Object.keys(j)) === JSON.stringify(['route', 'agent', 'model', 'alias', 'suggest', 'warning']));
183
+ ok('cli: the project config wins over the global one key by key', j.route === 'dispatch' && j.model === 'claude-opus-5' && j.alias === 'opus' && j.agent === 'trailhead-grill');
184
+ const g = JSON.parse(run(['grill', '--session', SONNET, tmp]).stdout);
185
+ ok('cli: with no project above, the global config applies', g.route === 'dispatch' && g.model === 'claude-sonnet-5' && g.alias === 'sonnet');
186
+ const ns = JSON.parse(run(['grill', '--session', 'unknown', '--no-subagents', proj]).stdout);
187
+ ok('cli: --no-subagents inline with a suggestion', ns.route === 'inline' && ns.suggest === 'claude-opus-5');
188
+ const cx = JSON.parse(run(['grill', '--session', 'gpt-5.6-terra', '--host', 'codex', proj]).stdout);
189
+ ok('cli: --host codex ignores models.grill', cx.route === 'inline');
190
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { codex: { grill: { model: 'gpt-5.6-sol', effort: 'high' } } } }));
191
+ const ce = JSON.parse(run(['grill', '--session', 'gpt-5.6-sol', '--session-effort', 'high', '--host', 'codex', proj]).stdout);
192
+ ok('cli: --session-effort equal to the pinned effort -> inline', ce.route === 'inline');
193
+ const cd = JSON.parse(run(['grill', '--session', 'gpt-5.6-sol', '--session-effort', 'medium', '--host', 'codex', proj]).stdout);
194
+ ok('cli: a different --session-effort -> dispatch', cd.route === 'dispatch' && cd.agent === 'trailhead-grill');
195
+ for (const [name, args] of [
196
+ ['a missing --session', ['grill', proj]],
197
+ ['an empty --session', ['grill', '--session', '', proj]],
198
+ ['an unknown flag', ['grill', '--session', SONNET, '--bogus', proj]],
199
+ ['an unknown key', ['nope', '--session', SONNET, proj]],
200
+ ['no key', ['--session', SONNET]],
201
+ ['an unknown host', ['grill', '--session', SONNET, '--host', 'mars', proj]],
202
+ ]) {
203
+ const x = run(args);
204
+ ok(`cli: ${name} exits 2 with nothing on stdout`, x.status === 2 && x.stdout === '' && x.stderr.length > 0);
205
+ }
206
+ // implicit grill per layer (project plan beats a global grill)
207
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { grill: 'claude-haiku-5-5' } }));
208
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-sonnet-5' } }));
209
+ const lp = JSON.parse(run(['grill', '--session', OPUS, proj]).stdout);
210
+ ok('cli: project plan + no project grill beats a global grill', lp.route === 'dispatch' && lp.model === 'claude-sonnet-5');
211
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-sonnet-5', grill: 'claude-opus-5' } }));
212
+ ok('cli: a project grill always wins', JSON.parse(run(['grill', '--session', SONNET, proj]).stdout).model === 'claude-opus-5');
213
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-sonnet-5' } }));
214
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: {} }));
215
+ ok('cli: global plan + no grill anywhere -> global plan', JSON.parse(run(['grill', '--session', OPUS, proj]).stdout).model === 'claude-sonnet-5');
216
+ fs.writeFileSync(path.join(globalDir, 'trailhead', 'config.json'), JSON.stringify({ models: { grill: 'claude-haiku-5-5', plan: 'claude-opus-5' } }));
217
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { plan: 'inherit session' } }));
218
+ ok('cli: project plan inherit session + global grill -> inline', JSON.parse(run(['grill', '--session', SONNET, proj]).stdout).route === 'inline');
219
+ // codex
220
+ const codexHome = path.join(tmp, 'codex');
221
+ fs.mkdirSync(path.join(codexHome, 'trailhead'), { recursive: true });
222
+ fs.writeFileSync(path.join(codexHome, 'trailhead', 'config.json'), JSON.stringify({ models: { codex: { grill: 'gpt-5.6-luna' } } }));
223
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: { codex: { plan: 'gpt-5.6-sol' } } }));
224
+ const cxl = JSON.parse(run(['grill', '--session', 'gpt-5.6-terra', '--host', 'codex', proj]).stdout);
225
+ ok('cli codex: project codex.plan + no project grill beats a global codex.grill', cxl.route === 'dispatch' && cxl.model === 'gpt-5.6-sol');
226
+ fs.writeFileSync(path.join(proj, '.trailhead', 'config.json'), JSON.stringify({ models: {} }));
227
+ ok('cli codex: only a global grill pin -> that pin', JSON.parse(run(['grill', '--session', 'gpt-5.6-terra', '--host', 'codex', proj]).stdout).model === 'gpt-5.6-luna');
228
+ fs.rmSync(tmp, { recursive: true, force: true });
229
+ }
230
+
231
+ console.log(`model-route.test.js: ${passed} assertions passed`);
@@ -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`);