@marcomigozzi/trailhead 0.16.0 → 0.17.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 (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -126,6 +126,87 @@ 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
+
129
210
  // --- fake tracker ------------------------------------------------------------
130
211
  function makeTracker(init = {}) {
131
212
  const t = {
@@ -396,6 +477,9 @@ function writeTmp(text) {
396
477
  const t3 = makeTracker();
397
478
  const r3 = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', '#'], t3);
398
479
  ok('remove ambiguous: exit 1, nothing written', r3.code === 1 && /ambiguous/.test(r3.out) && t3.writes.length === 0);
480
+ const tn = makeTracker({ body: '## Not yet specified\n1. **Repo to checkout resolution**: x\n2. **Other**: y\n' });
481
+ const rn = runMain(['remove', '--issue', '7', '--section', 'Not yet specified', '--match', 'Repo to checkout resolution'], tn);
482
+ 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
483
  const t4 = makeTracker();
400
484
  const r4 = runMain(['replace', '--issue', '7', '--section', 'Not yet specified', '--file', writeTmp('- only this\n')], t4);
401
485
  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,26 @@ 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');
20
+
21
+ // Host: la lib viene copiata byte-per-byte su Codex (sotto
22
+ // <codexHome>/skills/trailhead/hooks/lib), quindi l'host si deduce dal path
23
+ // installato, senza env (stessa idea di trailhead-check-update.js). Un
24
+ // argomento `host` esplicito sovrascrive la rilevazione (test, host futuri).
25
+ const CODEX_LIB_RE = /[\\/]skills[\\/]trailhead[\\/]hooks[\\/]lib$/;
26
+
27
+ function detectHost(dir = __dirname) {
28
+ return CODEX_LIB_RE.test(dir) ? 'codex' : 'claude';
29
+ }
30
+
31
+ function normalizeHost(host) {
32
+ return HOSTS.includes(host) ? host : detectHost();
33
+ }
34
+
35
+ // Forma del comando per host: `/trailhead:<verb>` su Claude, `$trailhead <verb>` su Codex.
36
+ function commandForm(host, verb) {
37
+ return normalizeHost(host) === 'codex' ? `$trailhead ${verb}` : `/trailhead:${verb}`;
38
+ }
19
39
 
20
40
  function isPlainObject(v) {
21
41
  return !!v && typeof v === 'object' && !Array.isArray(v);
@@ -75,7 +95,7 @@ function findTier(data, host, id, current) {
75
95
  }
76
96
 
77
97
  function isSkippable(id) {
78
- return !id || typeof id !== 'string' || id.trim() === '' || id === 'inherit session';
98
+ return !id || typeof id !== 'string' || id.trim() === '' || isInheritSession(id);
79
99
  }
80
100
 
81
101
  // Valuta un singolo pin (id) per un host e ritorna { from, to } se stale,
@@ -187,7 +207,24 @@ function effectiveModels(layers) {
187
207
  return { config: { models: outModels }, sources };
188
208
  }
189
209
 
190
- function reviewOffer({ config, data, ack, sources }) {
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
+
227
+ function reviewOffer({ config, data, ack, sources, host }) {
191
228
  const current = currentEntry(data);
192
229
  if (!current) return null;
193
230
  const stale = stalePins(config, data).map((s) => {
@@ -196,7 +233,7 @@ function reviewOffer({ config, data, ack, sources }) {
196
233
  });
197
234
  if (!stale.length) return null;
198
235
  if (ack && !semverLt(ack, current.since)) return null;
199
- return { since: current.since, stale, notice: reviewNotice(stale, current.since) };
236
+ return { since: current.since, stale, notice: reviewNotice(stale, current.since, host) };
200
237
  }
201
238
 
202
239
  function sourceSuffix(source) {
@@ -205,7 +242,7 @@ function sourceSuffix(source) {
205
242
  return ` (new default; from ${layerName}, ${source.file})`;
206
243
  }
207
244
 
208
- function reviewNotice(stale, since) {
245
+ function reviewNotice(stale, since, host) {
209
246
  const lines = stale.map((s) => `- ${s.key}: ${s.from} -> ${s.to}${sourceSuffix(s.source)}`);
210
247
  return [
211
248
  `trailhead's default models changed in ${since}. The following pinned model(s) are older than the new default:`,
@@ -214,7 +251,7 @@ function reviewNotice(stale, since) {
214
251
  'Offer the user, once, without blocking their current request, three choices:',
215
252
  '1) update those keys to the new defaults (edit each key in the config file it comes from, with exactly the values above; keep any codex effort unchanged),',
216
253
  '2) keep them as they are (nothing to do; trailhead will not ask again for these defaults once acknowledged),',
217
- '3) open /trailhead:config to pick per key.',
254
+ `3) open ${commandForm(host, 'config')} to pick per key.`,
218
255
  'This offer reappears every time it is checked until acknowledged: run the ack instruction below only after the user has picked one of the three choices above.',
219
256
  ].join('\n');
220
257
  }
@@ -231,25 +268,33 @@ function ackInstruction(root) {
231
268
  const ACK_FILE = path.join('.trailhead', 'model-defaults-ack');
232
269
  const CONFIG_FILE = path.join('.trailhead', 'config.json');
233
270
 
234
- // Dir base della config globale: stessa risoluzione di trailhead-check-update.js.
235
- function globalConfigDir() {
271
+ // Dir base della config globale. Claude: CLAUDE_CONFIG_DIR o ~/.claude (come
272
+ // trailhead-check-update.js).
273
+ // Su Codex e' <codexHome>, ricavato dal path installato della lib (4 livelli
274
+ // sopra __dirname: <codexHome>/skills/trailhead/hooks/lib); con host forzato
275
+ // fuori da un'installazione Codex ripiega su CODEX_HOME o ~/.codex.
276
+ function globalConfigDir(host) {
277
+ if (normalizeHost(host) === 'codex') {
278
+ if (detectHost() === 'codex') return path.resolve(__dirname, '..', '..', '..', '..');
279
+ return process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
280
+ }
236
281
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
237
282
  }
238
283
 
239
- function globalConfigFile() {
240
- return path.join(globalConfigDir(), 'trailhead', 'config.json');
284
+ function globalConfigFile(host) {
285
+ return path.join(globalConfigDir(host), 'trailhead', 'config.json');
241
286
  }
242
287
 
243
288
  // L'ack globale vive accanto al config.json globale, senza nidificare
244
289
  // ".trailhead" (che e' una convenzione solo di progetto).
245
- function globalAckFile() {
246
- return path.join(globalConfigDir(), 'trailhead', 'model-defaults-ack');
290
+ function globalAckFile(host) {
291
+ return path.join(globalConfigDir(host), 'trailhead', 'model-defaults-ack');
247
292
  }
248
293
 
249
294
  // Mancante o malformato -> {}: non deve mai bloccare la review di progetto.
250
- function loadGlobalConfig() {
295
+ function loadGlobalConfig(host) {
251
296
  try {
252
- const raw = JSON.parse(fs.readFileSync(globalConfigFile(), 'utf8'));
297
+ const raw = JSON.parse(fs.readFileSync(globalConfigFile(host), 'utf8'));
253
298
  return isPlainObject(raw) ? raw : {};
254
299
  } catch {
255
300
  return {};
@@ -296,13 +341,13 @@ function writeAck(root, since) {
296
341
  // non arriva un ack esplicito via ack(cwd). Valuta i pin EFFETTIVI: la
297
342
  // config globale unita sotto quella di progetto (progetto vince chiave per
298
343
  // chiave); senza un progetto sopra cwd, valuta la sola config globale.
299
- function check(cwd) {
344
+ function check(cwd, { host } = {}) {
300
345
  try {
301
346
  const resolvedCwd = path.resolve(cwd || '.');
302
347
  const root = findProjectRoot(cwd);
303
348
  const data = loadData();
304
- const globalFile = globalConfigFile();
305
- const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig() };
349
+ const globalFile = globalConfigFile(host);
350
+ const globalLayer = { layer: 'global', file: globalFile, config: loadGlobalConfig(host) };
306
351
 
307
352
  if (root) {
308
353
  let projectConfig;
@@ -318,7 +363,7 @@ function check(cwd) {
318
363
  };
319
364
  const { config, sources } = effectiveModels([globalLayer, projectLayer]);
320
365
  const ack = readAck(root);
321
- const offer = reviewOffer({ config, data, ack, sources });
366
+ const offer = reviewOffer({ config, data, ack, sources, host });
322
367
  if (!offer) return null;
323
368
  return `${offer.notice}\n${ackInstruction(root)}`;
324
369
  }
@@ -326,8 +371,8 @@ function check(cwd) {
326
371
  // Nessun progetto sopra cwd: valuta la sola config globale, se esiste.
327
372
  if (!fs.existsSync(globalFile)) return null;
328
373
  const { config, sources } = effectiveModels([globalLayer]);
329
- const ack = readAckFile(globalAckFile());
330
- const offer = reviewOffer({ config, data, ack, sources });
374
+ const ack = readAckFile(globalAckFile(host));
375
+ const offer = reviewOffer({ config, data, ack, sources, host });
331
376
  if (!offer) return null;
332
377
  return `${offer.notice}\n${ackInstruction(resolvedCwd)}`;
333
378
  } catch {
@@ -341,7 +386,7 @@ function check(cwd) {
341
386
  // globale -> ack globale. True se un target e' stato trovato e la scrittura
342
387
  // tentata (un fallimento silenzioso non e' riportato, comportamento
343
388
  // invariato); false se nessun target esiste. Non lancia mai eccezioni.
344
- function ack(cwd) {
389
+ function ack(cwd, { host } = {}) {
345
390
  try {
346
391
  const root = findProjectRoot(cwd);
347
392
  const data = loadData();
@@ -351,8 +396,8 @@ function ack(cwd) {
351
396
  writeAck(root, current.since);
352
397
  return true;
353
398
  }
354
- if (fs.existsSync(globalConfigFile())) {
355
- writeAckFile(globalAckFile(), current.since);
399
+ if (fs.existsSync(globalConfigFile(host))) {
400
+ writeAckFile(globalAckFile(host), current.since);
356
401
  return true;
357
402
  }
358
403
  return false;
@@ -368,12 +413,17 @@ module.exports = {
368
413
  applyUpdate,
369
414
  semverLt,
370
415
  effectiveModels,
416
+ withImplicitGrill,
417
+ isInheritSession,
371
418
  reviewOffer,
372
419
  reviewNotice,
420
+ detectHost,
421
+ commandForm,
373
422
  findProjectRoot,
374
423
  globalConfigDir,
375
424
  globalConfigFile,
376
425
  globalAckFile,
426
+ loadGlobalConfig,
377
427
  readAckFile,
378
428
  writeAckFile,
379
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' } } };
@@ -480,6 +543,56 @@ ok('stalePins: a non-object models.codex yields no codex pins (not an exception)
480
543
  ackOut === '' && fs.existsSync(path.join(globalDir, 'trailhead', 'model-defaults-ack')));
481
544
  }
482
545
 
546
+ // --- host-aware command form e global config (Codex) --------------------
547
+ {
548
+ const stale = [{ key: 'models.plan', from: 'claude-opus-4-8', to: 'claude-opus-5-5' }];
549
+ const codexNotice = reviewNotice(stale, cur.since, 'codex');
550
+ const claudeNotice = reviewNotice(stale, cur.since, 'claude');
551
+ ok('reviewNotice(codex) names $trailhead config', codexNotice.includes('open $trailhead config'));
552
+ ok('reviewNotice(codex) never names the Claude slash form', !codexNotice.includes('/trailhead:'));
553
+ ok('reviewNotice(claude) names /trailhead:config', claudeNotice.includes('open /trailhead:config'));
554
+ ok('reviewNotice default host is claude outside an installed Codex path',
555
+ reviewNotice(stale, cur.since).includes('/trailhead:config'));
556
+ }
557
+
558
+ {
559
+ // Lib copiata sotto un finto <codexHome>/skills/trailhead/hooks/lib/: l'host
560
+ // si deduce dal path, senza env, e la config globale e' <codexHome>/trailhead/.
561
+ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codexhome-'));
562
+ const libDir = path.join(codexHome, 'skills', 'trailhead', 'hooks', 'lib');
563
+ fs.mkdirSync(libDir, { recursive: true });
564
+ for (const f of ['model-defaults-review.js', 'model-defaults.json', 'inherit-session.js']) {
565
+ fs.copyFileSync(path.join(__dirname, f), path.join(libDir, f));
566
+ }
567
+ fs.mkdirSync(path.join(codexHome, 'trailhead'), { recursive: true });
568
+ fs.writeFileSync(path.join(codexHome, 'trailhead', 'config.json'), JSON.stringify({ models: { plan: 'claude-opus-4-8' } }));
569
+ const noProjDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-codex-noproj-'));
570
+ const copied = path.join(libDir, 'model-defaults-review.js');
571
+
572
+ const out = execFileSync('node', [copied, 'check', noProjDir], {
573
+ encoding: 'utf8',
574
+ env: { ...process.env, CLAUDE_CONFIG_DIR: BASE_CONFIG_DIR, CODEX_HOME: '' },
575
+ });
576
+ ok('Codex path: check() honours <codexHome>/trailhead/config.json', out.includes('models.plan: claude-opus-4-8 -> claude-opus-5-5'));
577
+ ok('Codex path: the notice offers $trailhead config', out.includes('open $trailhead config'));
578
+ ok('Codex path: the notice never names /trailhead:', !out.includes('/trailhead:'));
579
+ ok('Codex path: the notice tags the Codex global config path',
580
+ out.includes(path.join(codexHome, 'trailhead', 'config.json')));
581
+
582
+ execFileSync('node', [copied, 'ack', noProjDir], {
583
+ encoding: 'utf8',
584
+ env: { ...process.env, CLAUDE_CONFIG_DIR: BASE_CONFIG_DIR, CODEX_HOME: '' },
585
+ });
586
+ ok('Codex path: the global ack lands under <codexHome>/trailhead/',
587
+ fs.existsSync(path.join(codexHome, 'trailhead', 'model-defaults-ack')));
588
+
589
+ const mod = require(copied);
590
+ ok('Codex path: globalConfigDir resolves to the codexHome', mod.globalConfigDir() === codexHome);
591
+ ok('Codex path: check() is silenced after the Codex global ack', mod.check(noProjDir) === null);
592
+ ok('Claude behaviour unchanged: globalConfigDir still follows CLAUDE_CONFIG_DIR',
593
+ globalConfigDir() === BASE_CONFIG_DIR);
594
+ }
595
+
483
596
  console.log(`model-defaults-review.test.js: ${passed} assertions passed`);
484
597
 
485
598
  if (PRIOR_CLAUDE_CONFIG_DIR === undefined) {
@@ -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 };