@marcomigozzi/trailhead 0.16.1 → 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.
- package/README.it.md +57 -10
- package/README.md +57 -10
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/lib/codex-projection.js +22 -13
- package/bin/lib/codex-projection.test.js +58 -17
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/trailhead.js +7 -5
- package/bin/trailhead.test.js +53 -11
- package/bin/unblock-docs.test.js +56 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/mod/README.md +16 -7
- package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
- package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques.md +6 -4
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
- package/plugins/trailhead/templates/trailhead-capture.js +418 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- 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`);
|
|
@@ -217,6 +217,9 @@ const frows = [
|
|
|
217
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]],
|
|
218
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']],
|
|
219
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]],
|
|
220
223
|
// --- normalise: layout, fields intact ---
|
|
221
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,
|
|
222
225
|
{ commits: 'abc1234 feat: x', tests: 'ok', deviations: 'none', blockers: 'none' }, null,
|
|
@@ -253,6 +256,14 @@ const frows = [
|
|
|
253
256
|
'A feature about statuses:\n```\nstatus: pending\n```\nDone.']],
|
|
254
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]],
|
|
255
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']],
|
|
256
267
|
['the same label twice', EX, fullImpl('done', 'none', 'tests: again\n'), ['reask', null, 'fields']],
|
|
257
268
|
];
|
|
258
269
|
for (const [name, technique, input, expected] of frows) {
|
|
@@ -289,6 +300,13 @@ for (const [name, technique, input, expected] of frows) {
|
|
|
289
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');
|
|
290
301
|
}
|
|
291
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
|
+
|
|
292
310
|
// --- misuse ---------------------------------------------------------------------
|
|
293
311
|
ok('checkReport: unknown technique throws', (() => { try { checkReport('status: met', 'bogus'); } catch (e) { return true; } return false; })());
|
|
294
312
|
ok('checkReport: missing technique throws', (() => { try { checkReport('status: met'); } catch (e) { return true; } return false; })());
|
|
@@ -317,7 +335,7 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
|
|
|
317
335
|
const [lhs, rhs] = l.split('=>');
|
|
318
336
|
return { when: [...lhs.matchAll(/`([^`]+)`/g)].map((m) => m[1]), field: /`([^`]+)`/.exec(rhs)[1], rule: /(non-empty|none)\.$/.exec(rhs)[1] };
|
|
319
337
|
});
|
|
320
|
-
for (const family of ['implementing', 'investigating', 'plan']) {
|
|
338
|
+
for (const family of ['implementing', 'investigating', 'plan', 'grilling']) {
|
|
321
339
|
const block = blocks[family];
|
|
322
340
|
ok(`contracts: source defines the ${family} block`, typeof block === 'string');
|
|
323
341
|
ok(`contracts: ${family} tokens equal the source`, JSON.stringify(CONTRACTS[family].tokens) === JSON.stringify(words(block, 'statuses')));
|
|
@@ -411,7 +429,44 @@ const oneLine = (out) => { const lines = out.split('\n').filter(Boolean); return
|
|
|
411
429
|
const jf = oneLine(f.out);
|
|
412
430
|
ok('cli --file: a report over 64 KB arrives whole through a pipe', f.code === 0 && !!jf && jf.report === big);
|
|
413
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
|
+
}
|
|
414
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')]],
|
|
415
470
|
['no technique', ['--file', file('d.md', 'status: met')]],
|
|
416
471
|
['unknown technique', ['--technique', 'bogus', '--file', file('e.md', 'status: met')]],
|
|
417
472
|
['technique without a value', ['--technique']],
|
|
@@ -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 `
|
|
100
|
-
`
|
|
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.
|
|
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`,
|
|
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
|
|
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 /
|
|
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
|
|
@@ -269,7 +276,9 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
|
|
|
269
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.
|
|
270
277
|
Being unasked, it waits undrawn below 144 columns (`isPlaced: false`), typical of a fresh session; `/trailhead:panel`
|
|
271
278
|
then opens it again as the person's request (drawn at any width) instead of closing it, and closes it only when even
|
|
272
|
-
that leaves it undrawn (no surface
|
|
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.
|
|
273
282
|
- **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
|
|
274
283
|
`MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`, `TIMER_GRACE_MS = 30_000`,
|
|
275
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`.
|