@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
@@ -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`);
@@ -16,13 +16,15 @@ const fs = require('fs');
16
16
  const os = require('os');
17
17
 
18
18
  const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-report-status.js');
19
- const { checkReport, TOKENS, CONTRACTS, FAMILY_OF } = require(scriptPath);
19
+ const { checkReport, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES } = require(scriptPath);
20
20
  const techDir = path.resolve(__dirname, '..', '..', 'skills', '_shared', 'techniques');
21
21
 
22
22
  let passed = 0;
23
23
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
24
24
 
25
25
  const K = 'critical: 0 · warning: 1 · info: 2'; // the counts line
26
+ const K1 = 'critical: 1 · warning: 0 · info: 0';
27
+ const K2 = 'critical: 2 · warning: 1 · info: 0';
26
28
  const V = 'verify';
27
29
  const C = 'code-review';
28
30
  const KEYS = ['action', 'verdict', 'problem', 'report', 'fields', 'body'];
@@ -119,6 +121,60 @@ const rows = [
119
121
  ['two counts lines', C, `status: clean\n${K}\nbody\n${K}\n`, ['reask', null, 'counts']],
120
122
  ['two different counts lines', C, `status: clean\n${K}\ncritical: 1 · warning: 1 · info: 2\n`, ['reask', null, 'counts']],
121
123
  ['counts only inside a code fence', C, `status: clean\n\`\`\`\n${K}\n\`\`\`\n`, ['reask', null, 'counts']],
124
+
125
+ // --- verdict vs content: code-review fails closed on an unclosed Critical ---
126
+ ['clean with critical 2 and no ledger (ticket repro)', C, `status: clean\n${K2}\n\n- critical: SQL injection\n`,
127
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\n\n- critical: SQL injection\n`]],
128
+ ['clean, critical 1, C1 fixed', C, `status: clean\n${K1}\nC1 fixed (abc123)\n`, ['accept', 'clean', null]],
129
+ ['clean, critical 1, C1 deferred', C, `status: clean\n${K1}\n- C1: deferred → #12\n`, ['accept', 'clean', null]],
130
+ ['clean, critical 1, C1 accepted', C, `status: clean\n${K1}\nC1 accepted: false positive\n`, ['accept', 'clean', null]],
131
+ ['clean, critical 2, only C1 closed', C, `status: clean\n${K2}\nC1 fixed (a)\n`,
132
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\n`]],
133
+ ['clean, critical 1, C1 open', C, `status: clean\n${K1}\nC1 open\n`,
134
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 open\n`]],
135
+ ['clean, critical 1, C1 fixed and C2 open', C, `status: clean\n${K1}\nC1 fixed (a)\nC2 open\n`,
136
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed (a)\nC2 open\n`]],
137
+ ['clean, critical 1, only a reclassified pointer', C, `status: clean\n${K1}\nC1 reclassified as W3\n`,
138
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 reclassified as W3\n`]],
139
+ ['clean, critical 1, ledger line only inside a fence', C, `status: clean\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`,
140
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`]],
141
+ ['clean, critical 2, a repeated C1 line counts once', C, `status: clean\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`,
142
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`]],
143
+ ['finding prose with open after a closed ledger line stays clean', C, `status: clean\n${K1}\nC1: fixed (abc)\nC1 file.js:3 handler leaves the socket open\n`, ['accept', 'clean', null]],
144
+ ['open redirect prose with a fixed ledger stays clean', C, `status: clean\n${K1}\nC1 fixed (abc)\nC1 open redirect in login.js:9\n`, ['accept', 'clean', null]],
145
+ ['a lone fixed-size prose line does not close the Critical', C, `status: clean\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`,
146
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`]],
147
+ ['bold id and en-dash separator ledger line', C, `status: clean\n${K1}\n- **C1** – fixed (abc)\n`, ['accept', 'clean', null]],
148
+ ['verify subclass is not a class line', V, 'status: met\n- a, subclass: unmet\n', ['accept', 'met', null]],
149
+ ['an unparsed open line for an uncounted Critical fails closed', C, `status: clean\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`,
150
+ ['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`]],
151
+ ['em dash separator closes the Critical', C, `status: clean\n${K1}\nC1 ${String.fromCharCode(0x2014)} fixed (abc)\n`, ['accept', 'clean', null]],
152
+ ['still open fails closed', C, `status: clean\n${K1}\nC1: still open\nC2 fixed (a)\n`,
153
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1: still open\nC2 fixed (a)\n`]],
154
+ ['bracketed open fails closed', C, `status: clean\n${K1}\nC1 [open]\nC2 fixed (a)\n`,
155
+ ['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 [open]\nC2 fixed (a)\n`]],
156
+ ['a reclassified Critical does not gate the rest', C, `status: clean\n${K1}\nC1 reclassified as W3\nC2 fixed (a)\n`, ['accept', 'clean', null]],
157
+ ['clean, critical 0, warnings open never gate', C, `status: clean\n${K}\nW1 open\n`, ['accept', 'clean', null]],
158
+ ['issues_found is never changed', C, `status: issues_found\n${K1}\nC1 fixed (a)\n`, ['accept', 'issues_found', null]],
159
+
160
+ // --- verdict vs content: verify raises the tag to the highest claim class ---
161
+ ['verify met with a met claim', V, 'status: met\n- claim 1, class: met\n', ['accept', 'met', null]],
162
+ ['verify met with an unmet claim', V, 'status: met\n- Claim 2: login wired, class: unmet\n',
163
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- Claim 2: login wired, class: unmet\n']],
164
+ ['verify met with a partial claim', V, 'status: met\n- claim 1, class: partial\n',
165
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- claim 1, class: partial\n']],
166
+ ['verify met with an inconclusive claim', V, 'status: met\n- claim 1, class: inconclusive\n reason: unrunnable-check\n',
167
+ ['normalise', 'inconclusive', null, 'status: inconclusive\n- claim 1, class: inconclusive\n reason: unrunnable-check\n']],
168
+ ['verify met with inconclusive and unmet', V, 'status: met\n- a, class: inconclusive\n- b, class: unmet\n',
169
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- a, class: inconclusive\n- b, class: unmet\n']],
170
+ ['verify inconclusive with an unmet claim', V, 'status: inconclusive\n- b, class: unmet\n',
171
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- b, class: unmet\n']],
172
+ ['verify gaps_found with only met claims stays', V, 'status: gaps_found\n- a, class: met\n', ['accept', 'gaps_found', null]],
173
+ ['verify inconclusive with only met claims stays', V, 'status: inconclusive\n- a, class: met\n', ['accept', 'inconclusive', null]],
174
+ ['verify class line inside a fence is ignored', V, 'status: met\n```\n- a, class: unmet\n```\n', ['accept', 'met', null]],
175
+ ['verify class line inside a blockquote is ignored', V, 'status: met\n> - a, class: unmet\n', ['accept', 'met', null]],
176
+ ['verify upper-case class counts', V, 'status: met\n- a, Class: UNMET\n',
177
+ ['normalise', 'gaps_found', null, 'status: gaps_found\n- a, Class: UNMET\n']],
122
178
  ];
123
179
 
124
180
  for (const [name, technique, input, expected] of rows) {
@@ -161,6 +217,9 @@ const frows = [
161
217
  ['codebase-map needs_input with questions', 'codebase-map', 'status: needs_input\nanswer: partial\nevidence: a.js:1\nopen questions: which root?\n', ['accept', 'needs_input', null, null, null]],
162
218
  ['plan ready, body is the rest', 'plan', 'status: ready\n\n## Steps\n1. a\n2. b\n\nVerification: x\n', ['accept', 'ready', null, {}, '## Steps\n1. a\n2. b\n\nVerification: x']],
163
219
  ['plan needs_input', 'plan', 'status: needs_input\nquestions:\n1. Which db?\n2. Keep v1?\n', ['accept', 'needs_input', null, { questions: '1. Which db?\n2. Keep v1?' }, null]],
220
+ ['grilling question round', 'grill', 'status: question\nsettled: none\nfrontier: Q1, Q2\nquestion: 1. Which db?\nrecommended: A\nWeak point: it costs more.\namendments: none\nconflicts: none\n', ['accept', 'question', null,
221
+ { settled: 'none', frontier: 'Q1, Q2', question: '1. Which db?', recommended: 'A\nWeak point: it costs more.', amendments: 'none', conflicts: 'none' }, null]],
222
+ ['grilling review round', 'grill', 'status: review\nsettled: Q1 A, because x\nfrontier: none\namendments: Q1: tighten the wording\nconflicts: Q1 vs Q2: reconcile them. Weak point: none\n', ['accept', 'review', null, null, null]],
164
223
  // --- normalise: layout, fields intact ---
165
224
  ['bold label and token, bullets', EX, '**Status:** DONE\n- **commits:** abc1234 feat: x\n- tests: ok\n- deviations: none\n- blockers: none\n', ['normalise', 'done', null,
166
225
  { commits: 'abc1234 feat: x', tests: 'ok', deviations: 'none', blockers: 'none' }, null,
@@ -197,6 +256,14 @@ const frows = [
197
256
  'A feature about statuses:\n```\nstatus: pending\n```\nDone.']],
198
257
  ['a blockquoted status line inside an answer is ignored', 'research', 'status: found\nanswer: see\n> status: pending\nevidence: e\nopen questions: none\n', ['accept', 'found', null, null, null]],
199
258
  ['prose after the last field becomes its value, so a done report reasks', EX, fullImpl('done', 'none', 'Note: hi\n'), ['reask', null, 'fields']],
259
+ ['grilling question without recommended', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
260
+ ['grilling question with an empty question', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: none\nrecommended: A\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
261
+ ['grilling question with conflicts', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\nrecommended: A\namendments: none\nconflicts: Q1 vs Q2\n', ['reask', null, 'fields']],
262
+ ['grilling question with amendments', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\nrecommended: A\namendments: Q1: x\nconflicts: none\n', ['reask', null, 'fields']],
263
+ ['grilling review with a frontier', 'grill', 'status: review\nsettled: Q1 A\nfrontier: Q2\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
264
+ ['grilling review with a question', 'grill', 'status: review\nsettled: Q1 A\nfrontier: none\nquestion: 2. More?\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
265
+ ['grilling review without amendments', 'grill', 'status: review\nsettled: Q1 A\nfrontier: none\nconflicts: none\n', ['reask', null, 'fields']],
266
+ ['grilling: ready is an unknown token', 'grill', 'status: ready\nsettled: none\nfrontier: none\namendments: none\nconflicts: none\n', ['reask', null, 'unknown-token']],
200
267
  ['the same label twice', EX, fullImpl('done', 'none', 'tests: again\n'), ['reask', null, 'fields']],
201
268
  ];
202
269
  for (const [name, technique, input, expected] of frows) {
@@ -233,6 +300,13 @@ for (const [name, technique, input, expected] of frows) {
233
300
  ok('a report with an unterminated fence still parses its leading fields', checkReport('status: found\nanswer: a\nevidence: e\nopen questions: none\n```\nx', 'research').action === 'accept');
234
301
  }
235
302
 
303
+ {
304
+ const r = checkReport('status: review\nsettled: Q1 A\nfrontier: none\namendments: none\nconflicts: none\n', 'grill');
305
+ ok('grill: Weak point is not a field label and body is null', r.body === null && !('weak point' in r.fields));
306
+ const q = checkReport('status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which?\nrecommended: A\nWeak point: x\nWeak point: y\namendments: none\nconflicts: none\n', 'grill');
307
+ ok('grill: two Weak point lines do not trigger a reask', q.action === 'accept' && q.body === null);
308
+ }
309
+
236
310
  // --- misuse ---------------------------------------------------------------------
237
311
  ok('checkReport: unknown technique throws', (() => { try { checkReport('status: met', 'bogus'); } catch (e) { return true; } return false; })());
238
312
  ok('checkReport: missing technique throws', (() => { try { checkReport('status: met'); } catch (e) { return true; } return false; })());
@@ -261,7 +335,7 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
261
335
  const [lhs, rhs] = l.split('=>');
262
336
  return { when: [...lhs.matchAll(/`([^`]+)`/g)].map((m) => m[1]), field: /`([^`]+)`/.exec(rhs)[1], rule: /(non-empty|none)\.$/.exec(rhs)[1] };
263
337
  });
264
- for (const family of ['implementing', 'investigating', 'plan']) {
338
+ for (const family of ['implementing', 'investigating', 'plan', 'grilling']) {
265
339
  const block = blocks[family];
266
340
  ok(`contracts: source defines the ${family} block`, typeof block === 'string');
267
341
  ok(`contracts: ${family} tokens equal the source`, JSON.stringify(CONTRACTS[family].tokens) === JSON.stringify(words(block, 'statuses')));
@@ -274,6 +348,17 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
274
348
  ok(`contracts: technique ${technique} maps to the family of trailhead-${technique} in the sync script`, FAMILIES[`trailhead-${technique}`] === family);
275
349
  ok(`contracts: TOKENS.${technique} are the ${family} tokens`, JSON.stringify(TOKENS[technique]) === JSON.stringify(CONTRACTS[family].tokens));
276
350
  }
351
+ // the reviewing block names the verify class vocabulary and the closing dispositions
352
+ const bullet = (block, prefix) => block.split('\n').find((l) => l.startsWith(`- ${prefix}:`)) || '';
353
+ const classTokens = (text) => [...text.matchAll(/class: <([a-z|]+)>/g)].flatMap((m) => m[1].split('|'));
354
+ ok('contracts: reviewing verify bullet names the checker class vocabulary',
355
+ JSON.stringify(classTokens(bullet(blocks.reviewing, 'trailhead-verify'))) === JSON.stringify(VERIFY_CLASSES));
356
+ const crBullet = bullet(blocks.reviewing, 'trailhead-code-review');
357
+ ok('contracts: reviewing code-review bullet names the closing dispositions',
358
+ ['fixed', 'deferred', 'accepted'].every((d) => new RegExp(`\\x60${d}\\x60`).test(crBullet)));
359
+ const verifyDoc = fs.readFileSync(path.join(techDir, 'verify.md'), 'utf8');
360
+ ok('contracts: verify.md names the same class vocabulary',
361
+ classTokens(verifyDoc.replace(/class: ([a-z]+(?: \| [a-z]+)+)/g, (m, g) => `class: <${g.replace(/ \| /g, '|')}>`)).join() === VERIFY_CLASSES.join());
277
362
  ok('contracts: every non-review agent has a technique here',
278
363
  Object.entries(FAMILIES).filter(([, f]) => f !== 'reviewing').every(([a]) => FAMILY_OF[a.replace(/^trailhead-/, '')] !== undefined));
279
364
  }
@@ -344,7 +429,44 @@ const oneLine = (out) => { const lines = out.split('\n').filter(Boolean); return
344
429
  const jf = oneLine(f.out);
345
430
  ok('cli --file: a report over 64 KB arrives whole through a pipe', f.code === 0 && !!jf && jf.report === big);
346
431
  }
432
+ // --- --task-output: the report read from a background agent's transcript ----------
433
+ // The host's task .output is JSONL; one assistant message can span several lines
434
+ // (same message.id), and tool_use, user and bookkeeping lines sit between them.
435
+ const line = (o) => JSON.stringify(o);
436
+ const asst = (id, content) => line({ type: 'assistant', message: { id, role: 'assistant', content } });
437
+ const transcript = [
438
+ line({ type: 'user', message: { role: 'user', content: 'brief' } }),
439
+ asst('m1', [{ type: 'text', text: 'status: gaps_found\nearly draft' }]),
440
+ asst('m1', [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }]),
441
+ line({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }),
442
+ asst('m2', [{ type: 'text', text: 'status: met\n' }]),
443
+ asst('m2', [{ type: 'text', text: 'final body\n' }]),
444
+ line({ type: 'attachment', attachment: {} }),
445
+ 'not json at all',
446
+ '',
447
+ ].join('\n');
448
+ {
449
+ const { reportFromTaskOutput } = require(scriptPath);
450
+ ok('task output: the last assistant message, its text blocks joined in order',
451
+ reportFromTaskOutput(transcript) === 'status: met\nfinal body\n');
452
+ ok('task output: a trailing tool_use-only message is skipped for the last one with text',
453
+ reportFromTaskOutput(`${transcript}\n${asst('m3', [{ type: 'tool_use', id: 't2', name: 'Read', input: {} }])}`) === 'status: met\nfinal body\n');
454
+ ok('task output: string content counts as text', reportFromTaskOutput(asst('m9', 'status: met\nx')) === 'status: met\nx');
455
+ ok('task output: no assistant text is null', reportFromTaskOutput(line({ type: 'user', message: { content: 'hi' } })) === null);
456
+ const r = cli(['--technique', 'verify', '--task-output', file('agent.output', transcript)]);
457
+ const j = oneLine(r.out);
458
+ ok('cli --task-output: exit 0, the report checked as accept', r.code === 0 && !!j && j.action === 'accept' && j.verdict === 'met' && j.report === 'status: met\nfinal body\n');
459
+ const target = file('agent.jsonl', transcript);
460
+ const link = path.join(tmp, 'linked.output');
461
+ fs.symlinkSync(target, link);
462
+ const s = oneLine(cli(['--technique', 'verify', '--task-output', link]).out);
463
+ ok('cli --task-output: follows the host symlink to the transcript', !!s && s.verdict === 'met');
464
+ }
347
465
  const usageErrors = [
466
+ ['--file and --task-output together', ['--technique', 'verify', '--file', file('g.md', 'status: met'), '--task-output', file('g.output', transcript)]],
467
+ ['--task-output without a value', ['--technique', 'verify', '--task-output']],
468
+ ['--task-output with no assistant text', ['--technique', 'verify', '--task-output', file('h.output', line({ type: 'user', message: { content: 'x' } }))]],
469
+ ['unreadable --task-output', ['--technique', 'verify', '--task-output', path.join(tmp, 'nope.output')]],
348
470
  ['no technique', ['--file', file('d.md', 'status: met')]],
349
471
  ['unknown technique', ['--technique', 'bogus', '--file', file('e.md', 'status: met')]],
350
472
  ['technique without a value', ['--technique']],
@@ -633,6 +633,23 @@ const SEARCH_VERB_CONFIG = {
633
633
  awk: { patternFirst: true, valueFlags: new Set(['-f', '-v']), patternFlags: new Set() },
634
634
  };
635
635
 
636
+ // #316: the -f / --file operand of a flag token, or null when the token is not one.
637
+ // Forms: `-f V`, `-fV`, a short cluster whose FIRST value-taking letter is `f`
638
+ // (`-if.env`; after another value letter the rest is that flag's value, so
639
+ // `-ef.env` is a pattern), `--file V`, `--file=V`. `consumed` = extra tokens used.
640
+ function fileFlagOperand(t, next, config) {
641
+ if (t === '--file') return { value: next, consumed: 1 };
642
+ if (t.startsWith('--file=')) return { value: t.slice('--file='.length), consumed: 0 };
643
+ if (t.startsWith('--')) return null;
644
+ for (let i = 1; i < t.length; i++) {
645
+ if (!config.valueFlags.has(`-${t[i]}`)) continue;
646
+ if (t[i] !== 'f') return null;
647
+ const rest = t.slice(i + 1);
648
+ return rest ? { value: rest, consumed: 0 } : { value: next, consumed: 1 };
649
+ }
650
+ return null;
651
+ }
652
+
636
653
  // Walk a verb's argument tokens (verb itself already stripped), skipping
637
654
  // flags (and their separate-token values), an explicit `--` end-of-options
638
655
  // marker (which must not itself swallow the next real positional), and the
@@ -647,6 +664,14 @@ function scanArgsSkippingPattern(args, config, isMatch) {
647
664
  const t = args[j];
648
665
  if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
649
666
  if (!afterDashDash && t.startsWith('-') && t !== '-') {
667
+ // #316: the value of -f / --file is a FILE the verb reads, so it is tested.
668
+ const file = fileFlagOperand(t, args[j + 1], config);
669
+ if (file) {
670
+ if (file.value !== undefined && file.value !== '' && isMatch(file.value)) return file.value;
671
+ patternConsumed = true; // the pattern/script comes from that file, so positionals are FILEs
672
+ j += file.consumed;
673
+ continue;
674
+ }
650
675
  if (config.valueFlags.has(t)) {
651
676
  j++; // consume its separate-token value
652
677
  if (config.patternFlags && config.patternFlags.has(t)) patternConsumed = true;
@@ -136,5 +136,26 @@ eq('edge threshold', pct(1000, edge), 100);
136
136
  // finestra minuscola: soglia mai sotto 1
137
137
  eq('tiny window guard', pct(5, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '10' } }), 100);
138
138
 
139
- fs.rmSync(root, { recursive: true, force: true });
139
+ // --- credenziali: il blocco usage legge .credentials.json da CLAUDE_CONFIG_DIR (#320) ---
140
+ // Segnale osservabile: la dir cache viene creata solo se il guard sulle credenziali passa.
141
+ function usageReached({ withCreds }) {
142
+ const credCfg = path.join(root, withCreds ? 'cfg-creds' : 'cfg-nocreds');
143
+ const cache = path.join(root, withCreds ? 'cache-creds' : 'cache-nocreds');
144
+ fs.mkdirSync(credCfg, { recursive: true });
145
+ fs.mkdirSync(cache, { recursive: true });
146
+ if (withCreds) {
147
+ fs.writeFileSync(path.join(credCfg, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: 'dummy' } }));
148
+ }
149
+ const env = { ...process.env, NO_COLOR: '1', HOME: home, CLAUDE_CONFIG_DIR: credCfg, XDG_CACHE_HOME: cache,
150
+ USAGE_API_TIMEOUT: '1', HTTPS_PROXY: 'http://127.0.0.1:9', https_proxy: 'http://127.0.0.1:9' };
151
+ delete env.TRAILHEAD_STATUSLINE_USAGE;
152
+ const input = { model: { id: 'claude-opus-5-5', display_name: 'Test' }, workspace: { project_dir: proj, current_dir: proj } };
153
+ spawnSync('bash', [script, '--context-only'], { input: JSON.stringify(input), env, encoding: 'utf8' });
154
+ return fs.existsSync(path.join(cache, 'trailhead-statusline'));
155
+ }
156
+ ok('usage block reached with creds under CLAUDE_CONFIG_DIR', usageReached({ withCreds: true }));
157
+ ok('usage block skipped without creds', !usageReached({ withCreds: false }));
158
+
159
+ // retry: il python in background puo' ancora scrivere nella cache mentre si pulisce
160
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
140
161
  console.log(`statusline-context.test.js: ${passed} assertions passed`);
@@ -62,6 +62,11 @@ const WRAPPER_VALUE_FLAGS = {
62
62
  nohup: [],
63
63
  };
64
64
 
65
+ // Is `word` (as written, quotes allowed) a command wrapper from the list above? #316
66
+ function isWrapperWord(word) {
67
+ return Object.prototype.hasOwnProperty.call(WRAPPER_VALUE_FLAGS, dequote(word));
68
+ }
69
+
65
70
  // Index just past a wrapper's own flags (and assignments), from the wrapper at toks[k].
66
71
  function skipWrapper(toks, k) {
67
72
  const w = dequote(toks[k]);
@@ -342,4 +347,4 @@ function unmodelledReason(guard, what, hit, redact) {
342
347
  `interpret with certainty, so it cannot verify ${what}. Rewrite it in a simple form: ${hit.hint}.`;
343
348
  }
344
349
 
345
- module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason };
350
+ module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason, isWrapperWord };
@@ -96,11 +96,17 @@ command file (`commands/panel.md`), because a command registered by a mod cannot
96
96
  only where no mod does (it says the panel is not available). Claude Code only: the Codex
97
97
  projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex-projection.js`).
98
98
 
99
- - **What it shows**: the map as a link with its destination (dim), then `Needs you · n`, `Frontier · n`,
100
- `In progress · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). A row is `#n title`
99
+ - **What it shows**: the map as a link with its destination (dim), then `In progress · n` (first, right under
100
+ the maps list), `Needs you · n`, `Frontier · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). A row is `#n title`
101
101
  (a link to the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
102
102
  in play in this session is marked `▸` in the accent and says `this session` in place of its holder;
103
- blocked rows are dim. The `ownTicket` atom keeps that mark when another session's work state takes
103
+ blocked rows are dim. `In progress` is global for you: after the shown scope's own claimed tickets it lists your
104
+ open in-progress tickets (assigned to the `gh` login) of every other open map and of the whiteboard, each with a dim
105
+ `on map #<n> <title>` or `on whiteboard` line under it, in the map view and the Dashboard view alike (never a
106
+ ticket the view already lists; paused, blocked, UAT and needs-you ones stay in their own scope's groups). The login is
107
+ read once per host (`viewer{login}`), the tickets by one paged `gh api graphql` read of the open `trailhead:ticket`
108
+ issues with `filterBy:{assignee:<login>}`, in the same refresh as the view's reads; a failed or unreadable read keeps
109
+ the last list (the `myTickets` atom) and sets no footer error. The `ownTicket` atom keeps that mark when another session's work state takes
104
110
  over the shared file (a state stamped with another session leaves it; a missing file clears it only
105
111
  when the last state read was this session's, i.e. its own Resolve); with no own ticket the work
106
112
  state decides, as before. A ticket carrying `trailhead:paused` (set by pause, cleared by resume or a
@@ -108,7 +114,7 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
108
114
  button in place of its holder (it runs like `work`, with `/trailhead:resume <n>`, which checks the
109
115
  claim); the label wins over `trailhead:blocked` and an assignee.
110
116
  A ticket carrying `trailhead:needs-you` (an autonomous run parked it for the human; a `work`/`quick` claim
111
- clears it) shows once, under `Needs you`, above Frontier. The label wins over paused, UAT, blocked and an
117
+ clears it) shows once, under `Needs you`, between In progress and Frontier. The label wins over paused, UAT, blocked and an
112
118
  assignee. Any assignee is shown as its holder, never a button, even on a paused or blocked row. A blocked
113
119
  row with no assignee is dim with no button. Any other row gets a `work` button, never `resume`, even when
114
120
  paused: the claim clears the mark and resume would not. A
@@ -128,7 +134,8 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
128
134
  across the reset too. One press at a time: another while one is held or starting is ignored. A session that ends
129
135
  while a press is held drops it. A press while the session is not in the pinned repo (a shell `cd` moved it) still
130
136
  starts: the mod cannot move the session, so it appends the pinned checkout to the verb's args as `--in=<token>`
131
- (base64url of the root) and the verb `cd`s there first. With no repo pinned, or a checkout that cannot be read
137
+ (base64url of the root, a `.`, then base64url of the repo as `host/owner/name`) and the verb `cd`s there first;
138
+ if the root has moved, the verb finds the checkout again by the repo's `origin` remote. With no repo pinned, or a checkout that cannot be read
132
139
  (a removed worktree included), the press is refused with a toast before the `/clear`; both are checked again when
133
140
  a held press is sent. A refused `/clear`, or one that
134
141
  did not change the session, runs no work command. `trailhead:unverified` tickets are never listed,
@@ -185,7 +192,7 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
185
192
  shown before (the view is kept), a `Maps · n` group with every open map pressable (a press leaves the
186
193
  dashboard and views that map, the active one marked `●`), and a `Whiteboard · <n> open · <m> assigned` section (counted
187
194
  over the listed tickets; `200+ open` when the list is capped): the open
188
- `trailhead:ticket` + `trailhead:whiteboard` tickets as Needs you / Frontier / In progress / UAT / Paused / Blocked, with the same rows,
195
+ `trailhead:ticket` + `trailhead:whiteboard` tickets as In progress / Needs you / Frontier / UAT / Paused / Blocked, with the same rows,
189
196
  holders and `work` buttons (the work verb takes a whiteboard ticket as is). Unverified ones are counted only.
190
197
  Its reads replace the map's in a refresh: the whiteboard list (the GraphQL open read above), `gh issue list --label trailhead:dashboard
191
198
  --json number,url` and the maps list; a failed list keeps the last one with the footer reason, a failed index
@@ -252,19 +259,26 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
252
259
  timer started on open. A write is `gh issue edit|close|create|comment|reopen|delete|transfer`, a `gh api` call on a
253
260
  `repos/.../issues` path with a write method (`-X`/`--method` POST, PATCH, PUT, DELETE) or an implicit POST
254
261
  (`-f`, `-F`, `--field`, `--raw-field`, `--input`) and no `-X GET`, or the `trailhead-issue-body.js` helper
255
- (`GH_ISSUE_WRITE`); reads never refresh. Never two fetches inside `MIN_REFRESH_MS`: a request inside the floor
262
+ (`GH_ISSUE_WRITE`); reads never refresh. Never two fetches inside `MIN_REFRESH_MS` (save the turn-end read below): a request inside the floor
256
263
  keeps one trailing refresh for the rest of it. A write that reads at once also arms that trailing refresh, so one
257
264
  more read follows when the floor ends, after the search index (the history) caught up; a burst of writes still costs one
258
- read per floor plus one trailing. The host drops a refused timer without a word (a refused `every` period ends
265
+ read per floor plus one trailing, and one more when the turn ends after it. The host drops a refused timer without a word (a refused `every` period ends
259
266
  the interval), so the slow timer is kept with its due time: past it by more than `TIMER_GRACE_MS` it is held dead
260
- and the next trigger starts it again (`isTimerLate`). A read asked while the pane is undrawn is dropped; a render
267
+ and the next trigger starts it again (`isTimerLate`). The trailing refresh is kept with its due time the same way:
268
+ a dropped one is replaced by the next trigger, and a render past its due time by more than `TIMER_GRACE_MS` asks
269
+ for the read at once. A turn end that follows a tracker write newer than the last read start reads at once,
270
+ floor or not (one extra read per turn at most, never without a write), so the final state shows with no timer
271
+ involved; it leaves an armed trailing refresh alone, so the read after the search index caught up still follows.
272
+ A read asked while the pane is undrawn is dropped; a render
261
273
  whose list (with no error) is older than `TIMER_MS + TIMER_GRACE_MS` asks for a read at once, as an empty view does.
262
274
  - **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
263
275
  when a map is active. Never on the main screen, never again after the pane was opened or closed
264
276
  by the person (a press on the `close` button counts as closing it). The open runs from a zero-delay timer because a render may not write state.
265
277
  Being unasked, it waits undrawn below 144 columns (`isPlaced: false`), typical of a fresh session; `/trailhead:panel`
266
278
  then opens it again as the person's request (drawn at any width) instead of closing it, and closes it only when even
267
- that leaves it undrawn (no surface places panes).
279
+ that leaves it undrawn (the attached surfaces place no panes). With no surface attached yet (`no-surface`, the start of a session)
280
+ the pane stays open and the command answers that it will open once the screen is ready, again on every repeat; the
281
+ pane's first render on the surface that attaches asks for its read.
268
282
  - **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
269
283
  `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`, `TIMER_GRACE_MS = 30_000`,
270
284
  `PANEL_COMMAND = 'trailhead:panel'`, `MAPS_LIMIT = MAPS_READ_LIMIT = 100` (`MAPS_LIMIT` not exported); `worktree-porcelain.ts`: `MAX_FAILURES = 3` (shared by the work-state lookup and the run scan); `auto-run-view.ts`: `RESUME_HINT`; `map-pane-view.ts`: `MAPS_READ_LIMIT = 100`, `LIST_LIMIT = 200`, `HISTORY_LIMIT = 5000`.