@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
@@ -126,6 +126,168 @@ const EMPTY_DECISIONS = ['Intro line for the map, long enough.', '', '## Decisio
126
126
  ok('remove: missing section is an error', typeof miss.error === 'string');
127
127
  }
128
128
 
129
+ // --- applyOp: remove on numbered lists ---------------------------------------
130
+ {
131
+ const rm = (body, match) => applyOp(body, { kind: 'remove', section: 'Not yet specified', match });
132
+ const sec = (...l) => ['Intro line for the map, long enough.', '', '## Not yet specified', ...l, ''].join('\n');
133
+ const repro = rm('## Not yet specified\n1. **Repo to checkout resolution**: x\n2. **Other**: y\n', 'Repo to checkout resolution');
134
+ ok('remove numbered: ticket repro removes the entry and renumbers', repro.changed === true && repro.body === '## Not yet specified\n1. **Other**: y\n');
135
+ ok('remove numbered: summary unchanged', repro.summary === 'removed 1 entry from "Not yet specified"');
136
+ const mid = rm(sec('1. a', '2. b', '3. c'), 'b');
137
+ ok('remove numbered: following items renumbered', mid.body === sec('1. a', '2. c'));
138
+ const last = rm(sec('1. a', '2. b'), 'b');
139
+ ok('remove numbered: removing the last entry renumbers nothing', last.body === sec('1. a'));
140
+ const start = rm(sec('3. a', '4. b', '5. c'), 'a');
141
+ ok('remove numbered: start number preserved', start.body === sec('3. b', '4. c'));
142
+ const paren = rm(sec('1) a', '2) b', '3) c'), 'a');
143
+ ok('remove numbered: ) delimiter handled and kept', paren.body === sec('1) b', '2) c'));
144
+ const cont = rm(sec('1. a', ' detail of a', '2. b', ' detail of b', '3. c'), 'detail of a');
145
+ ok('remove numbered: 3-space continuation removed with its entry', cont.body === sec('1. b', ' detail of b', '2. c'));
146
+ const nested = rm(sec('1. a', ' 1. nested', '2. b'), 'a');
147
+ ok('remove numbered: an indented number is a continuation, not an entry', nested.body === sec('1. b'));
148
+ const amb = rm(sec('1. a x', '2. b x'), 'x');
149
+ ok('remove numbered: more than one match is ambiguous', typeof amb.error === 'string' && /ambiguous/.test(amb.error));
150
+ const body = sec('1. a', '2. b');
151
+ const none = rm(body, 'zzz');
152
+ ok('remove numbered: zero matches is a no-op', !none.error && none.changed === false && none.body === body);
153
+ const mixed = rm(sec('- bullet', '1. a', '2. b', '3. c'), 'a');
154
+ ok('remove numbered: bullet before the hit untouched, run after renumbered', mixed.body === sec('- bullet', '1. b', '2. c'));
155
+ const stop = rm(sec('1. a', '2. b', '- bullet', '3. c'), 'a');
156
+ ok('remove numbered: renumbering stops at a bullet', stop.body === sec('1. b', '- bullet', '3. c'));
157
+ const delim = rm(sec('1. a', '2. b', '3) c', '4) d'), 'a');
158
+ ok('remove numbered: renumbering stops at a different delimiter', delim.body === sec('1. b', '3) c', '4) d'));
159
+ const dec = rm(sec('1.5 x', '2. y'), 'y');
160
+ ok('remove numbered: 1.5 is not an item', dec.changed === true && dec.body === sec('1.5 x'));
161
+ const bullet = rm(sec('- a', '- b'), 'a');
162
+ ok('remove bullet: unchanged behaviour in a bullet section', bullet.body === sec('- b'));
163
+
164
+ // loose lists (blank lines between items)
165
+ const loose = sec('1. a', '', '2. b', '', '3. c', '', '4. d');
166
+ ok('remove loose: first item, numbering continues and no blank under the heading', rm(loose, 'a').body === sec('1. b', '', '2. c', '', '3. d'));
167
+ ok('remove loose: middle item, no double blank', rm(loose, 'b').body === sec('1. a', '', '2. c', '', '3. d'));
168
+ ok('remove loose: last item, no double blank', rm(loose, 'd').body === sec('1. a', '', '2. b', '', '3. c'));
169
+ const spaced = sec('', '1. a', '', '2. b', '', '3. c');
170
+ ok('remove loose: first item keeps the blank after the heading', rm(spaced, 'a').body === sec('', '1. b', '', '2. c'));
171
+ const half = rm(sec('1. a', '2. b', '', '3. c', '4. d'), 'a');
172
+ ok('remove loose: a run split by a blank line is renumbered across it', half.body === sec('1. b', '', '2. c', '3. d'));
173
+ ok('remove loose: tight neighbour before a blank keeps its spacing', rm(sec('1. a', '2. b', '', '3. c'), 'b').body === sec('1. a', '', '2. c'));
174
+
175
+ // loose items with an indented continuation paragraph
176
+ const para3 = '## Not yet specified\n\n1. a\n\n cont para\n\n2. b\n\n3. c';
177
+ ok('remove loose: item with a continuation paragraph removed whole, rest renumbered', rm(para3, 'a').body === '## Not yet specified\n\n1. b\n\n2. c');
178
+ ok('remove loose: matching the continuation paragraph removes the whole item', rm(para3, 'cont para').body === '## Not yet specified\n\n1. b\n\n2. c');
179
+ ok('remove loose: the item after a continuation paragraph is removed cleanly', rm(para3, 'b').body === '## Not yet specified\n\n1. a\n\n cont para\n\n2. c');
180
+ const bpara = '## Not yet specified\n\n- a\n\n cont para\n\n- b\n';
181
+ ok('remove loose: bullet with a continuation paragraph removed whole', rm(bpara, 'cont para').body === '## Not yet specified\n\n- b\n');
182
+
183
+ // the only item of a spaced section before the next heading
184
+ ok('remove loose: sole numbered item leaves a single blank before the next heading', rm('## Not yet specified\n\n1. a\n\n## Next', 'a').body === '## Not yet specified\n\n## Next');
185
+ ok('remove loose: sole bullet leaves a single blank before the next heading', rm('## Not yet specified\n\n- a\n\n## Next', 'a').body === '## Not yet specified\n\n## Next');
186
+
187
+ // CRLF
188
+ const crlf = rm(sec('1. a', '2. b', '3. c').replace(/\n/g, '\r\n'), 'a');
189
+ ok('remove numbered: CRLF preserved and renumbered', crlf.body === sec('1. b', '2. c').replace(/\n/g, '\r\n'));
190
+
191
+ // width changes
192
+ const wide = rm(sec('9. a', '10. b', '11. c'), 'a');
193
+ ok('remove numbered: renumbering across a width change', wide.body === sec('9. b', '10. c'));
194
+ const ten = rm(sec(...Array.from({ length: 10 }, (_, k) => `${k + 1}. item${k + 1}`)), '1. item1');
195
+ ok('remove numbered: a list reaching 10 items renumbered', ten.body === sec(...Array.from({ length: 9 }, (_, k) => `${k + 1}. item${k + 2}`)));
196
+
197
+ // run boundaries
198
+ const para = rm(sec('1. a', '2. b', '', 'A paragraph.', '3. c'), 'a');
199
+ ok('remove numbered: a paragraph ends the run, later numbering untouched', para.body === sec('1. b', '', 'A paragraph.', '3. c'));
200
+ const para2 = rm(sec('1. a', '', '2. b', '', 'A paragraph.', '', '3. c'), 'a');
201
+ ok('remove numbered: a paragraph after blank lines ends the run', para2.body === sec('1. b', '', 'A paragraph.', '', '3. c'));
202
+
203
+ // numbering style
204
+ const padded = rm(sec('09. a', '10. b', '11. c'), 'a');
205
+ ok('remove numbered: zero padding width kept', padded.body === sec('09. b', '10. c'));
206
+ const lazy = rm(sec('1. a', '1. b', '1. c'), 'a');
207
+ ok('remove numbered: an all-same-number list is not renumbered', lazy.body === sec('1. b', '1. c'));
208
+ }
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
+
129
291
  // --- fake tracker ------------------------------------------------------------
130
292
  function makeTracker(init = {}) {
131
293
  const t = {
@@ -396,6 +558,9 @@ function writeTmp(text) {
396
558
  const t3 = makeTracker();
397
559
  const r3 = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', '#'], t3);
398
560
  ok('remove ambiguous: exit 1, nothing written', r3.code === 1 && /ambiguous/.test(r3.out) && t3.writes.length === 0);
561
+ const tn = makeTracker({ body: '## Not yet specified\n1. **Repo to checkout resolution**: x\n2. **Other**: y\n' });
562
+ const rn = runMain(['remove', '--issue', '7', '--section', 'Not yet specified', '--match', 'Repo to checkout resolution'], tn);
563
+ ok('remove numbered via main: entry removed and rest renumbered', rn.code === 0 && /removed 1 entry/.test(rn.out) && tn.writes.length === 1 && tn.writes[0] === '## Not yet specified\n1. **Other**: y\n');
399
564
  const t4 = makeTracker();
400
565
  const r4 = runMain(['replace', '--issue', '7', '--section', 'Not yet specified', '--file', writeTmp('- only this\n')], t4);
401
566
  ok('replace via main: summary and write', r4.code === 0 && /replaced "Not yet specified"/.test(r4.out) && t4.writes[0].includes('- only this'));
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const DATA_FILE = path.join(__dirname, 'model-defaults.json');
17
17
  const HOSTS = ['claude', 'codex'];
18
18
  const TIERS = ['strong', 'standard', 'fast'];
19
+ const { isInheritSession } = require('./inherit-session.js');
19
20
 
20
21
  // Host: la lib viene copiata byte-per-byte su Codex (sotto
21
22
  // <codexHome>/skills/trailhead/hooks/lib), quindi l'host si deduce dal path
@@ -94,7 +95,7 @@ function findTier(data, host, id, current) {
94
95
  }
95
96
 
96
97
  function isSkippable(id) {
97
- return !id || typeof id !== 'string' || id.trim() === '' || id === 'inherit session';
98
+ return !id || typeof id !== 'string' || id.trim() === '' || isInheritSession(id);
98
99
  }
99
100
 
100
101
  // Valuta un singolo pin (id) per un host e ritorna { from, to } se stale,
@@ -206,6 +207,23 @@ function effectiveModels(layers) {
206
207
  return { config: { models: outModels }, sources };
207
208
  }
208
209
 
210
+ // Un valore "impostato" per il fallback di grill: non null/undefined/stringa vuota.
211
+ const isSetValue = (v) => v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '');
212
+
213
+ // #323: una layer di config che imposta plan ma non grill imposta implicitamente
214
+ // grill = plan per quella layer, PRIMA del merge progetto-su-globale (cosi' un
215
+ // progetto che ha scelto i suoi modelli fa grilling sul suo plan, anche se il
216
+ // globale fissa un grill). Vale per models.* e per models.codex.*. Funzione
217
+ // pura: non muta l'input; usata solo dal routing e dalla proiezione Codex,
218
+ // mai dalla review dei pin (un grill implicito non e' un pin dell'utente).
219
+ function withImplicitGrill(models) {
220
+ if (!isPlainObject(models)) return models;
221
+ const imply = (obj) => (isSetValue(obj.plan) && !isSetValue(obj.grill) ? { ...obj, grill: obj.plan } : obj);
222
+ const out = imply(models);
223
+ if (isPlainObject(models.codex)) return { ...out, codex: imply(models.codex) };
224
+ return out;
225
+ }
226
+
209
227
  function reviewOffer({ config, data, ack, sources, host }) {
210
228
  const current = currentEntry(data);
211
229
  if (!current) return null;
@@ -395,6 +413,8 @@ module.exports = {
395
413
  applyUpdate,
396
414
  semverLt,
397
415
  effectiveModels,
416
+ withImplicitGrill,
417
+ isInheritSession,
398
418
  reviewOffer,
399
419
  reviewNotice,
400
420
  detectHost,
@@ -403,6 +423,7 @@ module.exports = {
403
423
  globalConfigDir,
404
424
  globalConfigFile,
405
425
  globalAckFile,
426
+ loadGlobalConfig,
406
427
  readAckFile,
407
428
  writeAckFile,
408
429
  readAck,
@@ -34,6 +34,8 @@ const {
34
34
  globalAckFile,
35
35
  readAckFile,
36
36
  writeAckFile,
37
+ withImplicitGrill,
38
+ isInheritSession,
37
39
  } = require('./model-defaults-review.js');
38
40
 
39
41
  const LIB_PATH = path.join(__dirname, 'model-defaults-review.js');
@@ -295,6 +297,67 @@ ok('claude-haiku-5-5 itself is not stale',
295
297
  `models.codebase-map: claude-haiku-4-5-20251001 -> claude-haiku-5-5 (new default; from the project config, ${cfgPath})`));
296
298
  }
297
299
 
300
+ // --- #323: i pin grill sono valutati come ogni altra chiave (caratterizzazione) ---
301
+ {
302
+ const config = { models: { grill: 'claude-opus-5', codex: { grill: { model: 'gpt-5.5-sol', effort: 'high' } } } };
303
+ const stale = stalePins(config, data);
304
+ ok('stale models.grill pin is flagged and updated to the strong default',
305
+ stale.some((s) => s.key === 'models.grill' && s.from === 'claude-opus-5' && s.to === 'claude-opus-5-5'));
306
+ const next = applyUpdate({ models: { grill: 'claude-opus-5' } }, stalePins({ models: { grill: 'claude-opus-5' } }, data));
307
+ ok('applyUpdate rewrites models.grill', next.models.grill === 'claude-opus-5-5');
308
+ const codexStale = stalePins({ models: { codex: { grill: { model: 'gpt-5.6-terra', effort: 'high' } } } }, data);
309
+ ok('models.codex.grill at the current default is not flagged', codexStale.length === 0);
310
+ }
311
+
312
+ // --- #323: una config senza grill (chiave nata dopo) non genera nulla per grill ---
313
+ {
314
+ const sevenOld = { plan: 'claude-opus-5-5', execute: 'claude-sonnet-5-5', research: 'claude-sonnet-5-5', review: 'claude-sonnet-5-5',
315
+ debug: 'claude-sonnet-5-5', verify: 'claude-sonnet-5-5', 'codebase-map': 'claude-haiku-5-5',
316
+ codex: { plan: { model: 'gpt-5.6-sol', effort: 'high' }, execute: 'gpt-5.6-terra' } };
317
+ const cfg = { models: sevenOld };
318
+ ok('seven old keys and no grill: no stale pin names grill', stalePins(cfg, data).every((s) => !/grill/.test(s.key)));
319
+ ok('seven old keys at current defaults: the review offers nothing', reviewOffer({ config: cfg, data, ack: null }) === null);
320
+ const stale = { models: { ...sevenOld, plan: 'claude-opus-5' } };
321
+ const offer = reviewOffer({ config: stale, data, ack: null });
322
+ ok('a stale plan pin is flagged but the notice never mentions grill', offer !== null && !offer.notice.includes('grill'));
323
+ ok('applyUpdate does not invent a grill key', !('grill' in applyUpdate(stale, stalePins(stale, data)).models));
324
+ }
325
+
326
+ {
327
+ const mod = require('./inherit-session.js');
328
+ const src = fs.readFileSync(path.join(__dirname, 'inherit-session.js'), 'utf8');
329
+ ok('inherit-session.js is dependency-free (no require)', !/require\(/.test(src));
330
+ ok('model-defaults-review re-exports the same isInheritSession', mod.isInheritSession === isInheritSession);
331
+ const projSrc = fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'bin', 'lib', 'codex-projection.js'), 'utf8');
332
+ ok('codex-projection requires the dependency-free module, not model-defaults-review',
333
+ projSrc.includes("hooks/lib/inherit-session.js") && !projSrc.includes('model-defaults-review'));
334
+ ok('isSkippable semantics: an inherit session pin is never flagged stale',
335
+ stalePins({ models: { plan: ' Inherit Session ' } }, data).length === 0);
336
+ }
337
+
338
+ // --- #323: isInheritSession e' l'unico riconoscimento di "inherit session" ---
339
+ ok('isInheritSession: bare string, trimmed, any case',
340
+ isInheritSession('inherit session') && isInheritSession(' Inherit Session ') && isInheritSession('INHERIT SESSION'));
341
+ ok('isInheritSession: the model field of an object pin', isInheritSession({ model: 'inherit session', effort: 'high' }) && isInheritSession({ model: ' Inherit Session' }));
342
+ ok('isInheritSession: real ids and non-strings are not inherit',
343
+ !isInheritSession('gpt-5.6-sol') && !isInheritSession({ model: 'gpt-5.6-sol' }) && !isInheritSession(null) && !isInheritSession(undefined) && !isInheritSession({}) && !isInheritSession(3));
344
+
345
+ // --- #323: una layer che imposta plan ma non grill imposta grill = plan ---
346
+ {
347
+ const src = { plan: 'p', codex: { plan: 'cp' }, execute: 'e' };
348
+ const out = withImplicitGrill(src);
349
+ ok('withImplicitGrill: plan without grill implies grill (claude and codex)', out.grill === 'p' && out.codex.grill === 'cp');
350
+ ok('withImplicitGrill: does not mutate the input', !('grill' in src) && !('grill' in src.codex));
351
+ ok('withImplicitGrill: an explicit grill is kept', withImplicitGrill({ plan: 'p', grill: 'g' }).grill === 'g');
352
+ ok('withImplicitGrill: null or blank grill counts as unset', withImplicitGrill({ plan: 'p', grill: null }).grill === 'p' && withImplicitGrill({ plan: 'p', grill: ' ' }).grill === 'p');
353
+ ok('withImplicitGrill: no plan, no implied grill', !('grill' in withImplicitGrill({ execute: 'e' })));
354
+ ok('withImplicitGrill: a blank plan implies nothing', !('grill' in withImplicitGrill({ plan: '' })));
355
+ ok('withImplicitGrill: inherit session plan implies an inherit grill', withImplicitGrill({ plan: 'inherit session' }).grill === 'inherit session');
356
+ ok('withImplicitGrill: non-objects pass through', withImplicitGrill(null) === null && withImplicitGrill('x') === 'x');
357
+ const stale = { models: { plan: 'claude-opus-5' } };
358
+ ok('the pinned model review does not use the implicit grill', stalePins(stale, data).every((s) => s.key !== 'models.grill'));
359
+ }
360
+
298
361
  // --- effectiveModels (pure merge, no mutation) --------------------------------
299
362
  {
300
363
  const globalModels = { plan: 'g-plan', execute: 'g-exec', codex: { plan: { model: 'g-gpt', effort: 'high' } } };
@@ -498,7 +561,7 @@ ok('stalePins: a non-object models.codex yields no codex pins (not an exception)
498
561
  const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codexhome-'));
499
562
  const libDir = path.join(codexHome, 'skills', 'trailhead', 'hooks', 'lib');
500
563
  fs.mkdirSync(libDir, { recursive: true });
501
- for (const f of ['model-defaults-review.js', 'model-defaults.json']) {
564
+ for (const f of ['model-defaults-review.js', 'model-defaults.json', 'inherit-session.js']) {
502
565
  fs.copyFileSync(path.join(__dirname, f), path.join(libDir, f));
503
566
  }
504
567
  fs.mkdirSync(path.join(codexHome, 'trailhead'), { recursive: true });
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+ // model-route.js: decide dove gira un'attivita' legata a una chiave models.*
3
+ // (inline nella sessione oppure dispatch a un subagent), per host. #323.
4
+ // Funzione pura route() piu' una CLI che carica la config effettiva (globale
5
+ // poi progetto, uniti da effectiveModels di model-defaults-review.js).
6
+ // Per la chiave grill, un valore non impostato segue models.plan (e su Codex
7
+ // models.codex.plan): non ha un default Balanced proprio. #323.
8
+ // E' il router di ogni step del motore (plan, execute, fix via execute, review,
9
+ // verify, research, debug, codebase-map, grill): il paragrafo canonico e'
10
+ // "Route every step through model-route.js" in skills/_shared/techniques.md. #325.
11
+ //
12
+ // CLI:
13
+ // node model-route.js <key> --session <id completo|unknown> [--session-effort <e>]
14
+ // [--host claude|codex] [--no-subagents] [<dir>]
15
+ // Stampa una riga JSON {"route","agent","model","alias","suggest","warning"}.
16
+ // Exit 2 (errore JSON su stderr, niente su stdout) se manca --session o e'
17
+ // vuoto, con un flag sconosciuto, un host o una chiave sconosciuti.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const mdr = require('./model-defaults-review.js');
22
+ const { isInheritSession } = require('./inherit-session.js');
23
+
24
+ const UNKNOWN_SESSION = 'unknown'; // valore riservato: non e' mai uguale a un id
25
+ const HOSTS = ['claude', 'codex'];
26
+ const ALIASES = ['opus', 'sonnet', 'haiku', 'fable'];
27
+
28
+ // Chiave -> nome dell'agent, per host. Su Codex i nomi sono quelli che la
29
+ // proiezione TOML emette (codexAgentTomlPlan); su Claude quelli sotto agents/.
30
+ const AGENT_OF = Object.freeze({
31
+ claude: Object.freeze({
32
+ plan: 'trailhead-plan',
33
+ execute: 'trailhead-executor',
34
+ research: 'trailhead-research',
35
+ review: 'trailhead-code-review',
36
+ debug: 'trailhead-debug',
37
+ verify: 'trailhead-verify',
38
+ 'codebase-map': 'trailhead-codebase-map',
39
+ grill: 'trailhead-grill',
40
+ }),
41
+ codex: Object.freeze({
42
+ plan: 'trailhead-plan',
43
+ execute: 'trailhead-execute',
44
+ research: 'trailhead-research',
45
+ review: 'trailhead-review',
46
+ debug: 'trailhead-debug',
47
+ verify: 'trailhead-verify',
48
+ 'codebase-map': 'trailhead-codebase-map',
49
+ grill: 'trailhead-grill',
50
+ }),
51
+ });
52
+
53
+ // Riga Balanced della tabella dei profili (configuration-reference.md), senza
54
+ // grill (un grill non impostato segue plan); il test la tiene allineata.
55
+ const BALANCED = Object.freeze({
56
+ plan: 'strong',
57
+ execute: 'standard',
58
+ research: 'standard',
59
+ review: 'standard',
60
+ debug: 'standard',
61
+ verify: 'standard',
62
+ 'codebase-map': 'fast',
63
+ });
64
+
65
+ // Famiglia (alias accettato da Agent su Claude Code) dell'id, o null.
66
+ function familyAlias(id) {
67
+ if (typeof id !== 'string') return null;
68
+ return ALIASES.find((a) => id.toLowerCase().includes(a)) || null;
69
+ }
70
+
71
+ // Id dei tier dell'host dall'entry corrente di model-defaults.json.
72
+ function loadTierIds(host) {
73
+ const current = mdr.currentEntry(mdr.loadData());
74
+ return (current && current[host]) || {};
75
+ }
76
+
77
+ // Toglie un suffisso finale `[...]` (es. la finestra di contesto `[1m]`): lo
78
+ // stesso modello con o senza suffisso resta lo stesso modello.
79
+ const stripSuffix = (id) => (typeof id === 'string' ? id.replace(/\[[^\]]*\]\s*$/, '').trim() : id);
80
+
81
+ const isUnset = (v) => v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
82
+ const result = (fields) => ({ route: 'inline', agent: null, model: null, alias: null, suggest: null, warning: null, ...fields });
83
+
84
+ // Grill non impostato segue plan (valore risolto di models.plan, o il default
85
+ // Balanced di plan): ritorna { raw, tierKey } per la chiave data.
86
+ function resolveRaw(source, key) {
87
+ const own = source ? source[key] : undefined;
88
+ if (key === 'grill' && isUnset(own)) return { raw: source ? source.plan : undefined, tierKey: 'plan' };
89
+ return { raw: own, tierKey: key };
90
+ }
91
+
92
+ function routeClaude({ key, models, sessionModel, subagents, tierIds }) {
93
+ const { raw, tierKey } = resolveRaw(models, key);
94
+ if (isInheritSession(raw)) return result({});
95
+ const id = stripSuffix(isUnset(raw) ? (tierIds || {})[BALANCED[tierKey]] : raw);
96
+ if (typeof id !== 'string' || id.trim() === '') return result({ warning: 'unknown-id' });
97
+ const sameAsSession = sessionModel !== UNKNOWN_SESSION && id === sessionModel;
98
+
99
+ if (subagents === false) {
100
+ return result({ model: id, suggest: sameAsSession ? null : id });
101
+ }
102
+ if (sameAsSession) return result({ model: id });
103
+ const alias = familyAlias(id);
104
+ if (!alias) return result({ model: id, warning: 'unknown-id' });
105
+ return result({ route: 'dispatch', agent: AGENT_OF.claude[key], model: id, alias });
106
+ }
107
+
108
+ function routeCodex({ key, models, sessionModel, sessionEffort, subagents }) {
109
+ const codexModels = models && models.codex && typeof models.codex === 'object' && !Array.isArray(models.codex) ? models.codex : {};
110
+ const { raw } = resolveRaw(codexModels, key);
111
+ if (isUnset(raw) || isInheritSession(raw)) return result({});
112
+ let pin = typeof raw === 'object' ? raw : { model: raw };
113
+ if (typeof pin.model !== 'string' || pin.model.trim() === '') return result({});
114
+ pin = { ...pin, model: stripSuffix(pin.model) };
115
+
116
+ const sameModel = sessionModel !== UNKNOWN_SESSION && pin.model === sessionModel;
117
+ if (subagents === false) return result({ model: pin.model, suggest: sameModel ? null : pin.model });
118
+ // Solo il TOML proiettato onora model_reasoning_effort: a parita' di modello
119
+ // si resta inline solo se non c'e' un effort pinnato, o se coincide con quello di sessione.
120
+ if (sameModel) {
121
+ if (!pin.effort) return result({ model: pin.model });
122
+ if (sessionEffort && sessionEffort === pin.effort) return result({ model: pin.model });
123
+ }
124
+ return result({ route: 'dispatch', agent: AGENT_OF.codex[key], model: pin.model });
125
+ }
126
+
127
+ function route({ key, host, models, sessionModel, sessionEffort, subagents, tierIds } = {}) {
128
+ if (!HOSTS.includes(host)) throw new Error(`unknown host: ${host}`);
129
+ if (!Object.prototype.hasOwnProperty.call(AGENT_OF[host], key)) throw new Error(`unknown key: ${key}`);
130
+ const args = { key, models, sessionModel: stripSuffix(sessionModel), sessionEffort, subagents, tierIds };
131
+ return host === 'codex' ? routeCodex(args) : routeClaude(args);
132
+ }
133
+
134
+ // --- CLI -------------------------------------------------------------------
135
+ function parseArgs(argv) {
136
+ const opts = { key: null, session: null, sessionEffort: null, host: null, subagents: true, dir: null };
137
+ for (let i = 0; i < argv.length; i++) {
138
+ const a = argv[i];
139
+ if (a === '--no-subagents') {
140
+ opts.subagents = false;
141
+ } else if (a === '--session' || a === '--session-effort' || a === '--host') {
142
+ const v = argv[i + 1];
143
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
144
+ opts[a === '--session' ? 'session' : a === '--host' ? 'host' : 'sessionEffort'] = v;
145
+ i++;
146
+ } else if (a.startsWith('--')) {
147
+ throw new Error(`unknown flag: ${a}`);
148
+ } else if (opts.key === null) {
149
+ opts.key = a;
150
+ } else if (opts.dir === null) {
151
+ opts.dir = a;
152
+ } else {
153
+ throw new Error(`unexpected argument: ${a}`);
154
+ }
155
+ }
156
+ if (!opts.key) throw new Error('a key is required');
157
+ if (opts.session === null || opts.session.trim() === '') throw new Error('--session <full model id|unknown> is required');
158
+ if (opts.host !== null && !HOSTS.includes(opts.host)) throw new Error(`unknown host: ${opts.host}`);
159
+ return opts;
160
+ }
161
+
162
+ // Config effettiva: globale poi progetto (il progetto vince chiave per chiave).
163
+ function effectiveConfig(host, dir) {
164
+ // Ogni layer: plan senza grill implica grill = plan, prima del merge (#323).
165
+ const implied = (config) => (config && config.models ? { ...config, models: mdr.withImplicitGrill(config.models) } : config);
166
+ const layers = [{ layer: 'global', file: mdr.globalConfigFile(host), config: implied(mdr.loadGlobalConfig(host)) }];
167
+ const projectRoot = mdr.findProjectRoot(dir);
168
+ if (projectRoot) {
169
+ const file = path.join(projectRoot, '.trailhead', 'config.json');
170
+ try {
171
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
172
+ layers.push({ layer: 'project', file, config: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? implied(parsed) : {} });
173
+ } catch {
174
+ /* config di progetto illeggibile: vale come assente */
175
+ }
176
+ }
177
+ return mdr.effectiveModels(layers).config;
178
+ }
179
+
180
+ function main(argv, io = {}) {
181
+ const stdout = io.stdout || process.stdout;
182
+ const stderr = io.stderr || process.stderr;
183
+ let out;
184
+ try {
185
+ const opts = parseArgs(argv);
186
+ const host = opts.host || mdr.detectHost();
187
+ const models = effectiveConfig(host, opts.dir || process.cwd()).models;
188
+ out = route({
189
+ key: opts.key,
190
+ host,
191
+ models,
192
+ sessionModel: opts.session,
193
+ sessionEffort: opts.sessionEffort,
194
+ subagents: opts.subagents,
195
+ tierIds: loadTierIds('claude'),
196
+ });
197
+ } catch (e) {
198
+ stderr.write(`${JSON.stringify({ error: e.message })}\n`);
199
+ return 2;
200
+ }
201
+ stdout.write(`${JSON.stringify({ route: out.route, agent: out.agent, model: out.model, alias: out.alias, suggest: out.suggest, warning: out.warning })}\n`);
202
+ return 0;
203
+ }
204
+
205
+ if (require.main === module) process.exitCode = main(process.argv.slice(2));
206
+
207
+ module.exports = { route, parseArgs, main, effectiveConfig, familyAlias, loadTierIds, AGENT_OF, BALANCED };