@marcomigozzi/trailhead 0.12.1 → 0.14.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 (66) hide show
  1. package/README.it.md +38 -13
  2. package/README.md +48 -13
  3. package/bin/lib/codex-projection.js +18 -11
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
  38. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
  39. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  40. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  42. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  45. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  46. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
  47. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  48. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  49. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  50. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  51. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  52. package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
  53. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  56. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  57. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  58. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  59. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  60. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  61. package/plugins/trailhead/tests/mod.test.ts +14 -0
  62. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  63. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  64. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  65. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  66. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-work-state.js. Run: node work-state.test.js
3
+ // No framework: plain asserts, same style as commit-msg-sync.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. Pure mergeState() / sanitizeState().
7
+ // 2. main() with injected cwd/env/stdout against mkdtemp folders.
8
+ // 3. Fault injection: an fs whose write or rename throws.
9
+ // 4. End-to-end: execFileSync of the script itself.
10
+ const assert = require('assert');
11
+ const { execFileSync, spawnSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-work-state.js');
17
+ const { mergeState, sanitizeState, parseArgs, main } = require(scriptPath);
18
+
19
+ let passed = 0;
20
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
21
+
22
+ function mktmp() {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-work-state-test-'));
24
+ }
25
+
26
+ // Runs main() capturing stdout and stderr; returns { code, lines, err }.
27
+ function run(argv, { cwd, env = {}, fsMod } = {}) {
28
+ const chunks = [];
29
+ const errChunks = [];
30
+ const realErr = process.stderr.write.bind(process.stderr);
31
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
32
+ let code;
33
+ try {
34
+ code = main(argv, { cwd, env, stdout: { write: (s) => chunks.push(s) }, ...(fsMod ? { fs: fsMod } : {}) });
35
+ } finally {
36
+ process.stderr.write = realErr;
37
+ }
38
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
39
+ return { code, lines, err: errChunks.join('') };
40
+ }
41
+
42
+ const statePath = (dir) => path.join(dir, '.trailhead', 'work-state.json');
43
+ const readState = (dir) => JSON.parse(fs.readFileSync(statePath(dir), 'utf8'));
44
+ const trailheadFiles = (dir) => fs.existsSync(path.join(dir, '.trailhead')) ? fs.readdirSync(path.join(dir, '.trailhead')) : [];
45
+
46
+ // --- fresh set ---------------------------------------------------------------
47
+ {
48
+ const dir = mktmp();
49
+ const r = run(['set', '--repo', dir, '--map', '12', '--ticket', '205', '--title', 'Write it', '--step', 'discuss'],
50
+ { cwd: '/', env: { CLAUDE_CODE_SESSION_ID: 'sess-1' } });
51
+ const s = readState(dir);
52
+ ok('fresh set: exit 0', r.code === 0);
53
+ ok('fresh set: one JSON line, status written', r.lines.length === 1 && r.lines[0].status === 'written');
54
+ ok('fresh set: output carries path and state', r.lines[0].path === statePath(dir) && r.lines[0].state.ticket === 205);
55
+ ok('fresh set: exact key set',
56
+ JSON.stringify(Object.keys(s)) === JSON.stringify(['map', 'ticket', 'title', 'step', 'auto', 'autoDone', 'session', 'updatedAt']));
57
+ ok('fresh set: values', s.map === 12 && s.ticket === 205 && s.title === 'Write it' && s.step === 'discuss' && s.auto === false);
58
+ ok('fresh set: session from env', s.session === 'sess-1');
59
+ ok('fresh set: updatedAt is ISO-8601', new Date(s.updatedAt).toISOString() === s.updatedAt);
60
+ ok('fresh set: no temp file left', JSON.stringify(trailheadFiles(dir)) === JSON.stringify(['work-state.json']));
61
+ }
62
+
63
+ // --- --repo default is cwd ---------------------------------------------------
64
+ {
65
+ const dir = mktmp();
66
+ run(['set', '--ticket', '1', '--step', 'plan'], { cwd: dir });
67
+ ok('repo defaults to cwd', readState(dir).ticket === 1);
68
+ }
69
+
70
+ // --- session absent / empty --------------------------------------------------
71
+ {
72
+ const dir = mktmp();
73
+ run(['set', '--ticket', '1', '--step', 'plan'], { cwd: dir, env: {} });
74
+ ok('session absent -> null', readState(dir).session === null);
75
+ const dir2 = mktmp();
76
+ run(['set', '--ticket', '1', '--step', 'plan'], { cwd: dir2, env: { CLAUDE_CODE_SESSION_ID: '' } });
77
+ ok('session empty -> null', readState(dir2).session === null);
78
+ }
79
+
80
+ // --- merge on step change ----------------------------------------------------
81
+ {
82
+ const dir = mktmp();
83
+ run(['set', '--map', '12', '--ticket', '205', '--title', 'T', '--step', 'discuss'], { cwd: dir });
84
+ const before = readState(dir);
85
+ const r = run(['set', '--step', 'plan'], { cwd: dir });
86
+ const s = readState(dir);
87
+ ok('merge: step-only set exits 0', r.code === 0);
88
+ ok('merge: step updated, rest kept', s.step === 'plan' && s.map === 12 && s.ticket === 205 && s.title === 'T');
89
+ ok('merge: updatedAt not older', s.updatedAt >= before.updatedAt);
90
+ const r2 = run(['set', '--auto', 'true'], { cwd: dir });
91
+ ok('merge: auto-only set allowed on a complete file', r2.code === 0 && readState(dir).auto === true && readState(dir).step === 'plan');
92
+ run(['set', '--auto', 'false'], { cwd: dir });
93
+ ok('merge: auto back to false', readState(dir).auto === false);
94
+ run(['set', '--ticket', '205', '--step', 'execute'], { cwd: dir });
95
+ ok('merge: same ticket keeps title and map', readState(dir).title === 'T' && readState(dir).map === 12);
96
+ }
97
+
98
+ // --- new ticket resets -------------------------------------------------------
99
+ {
100
+ const dir = mktmp();
101
+ run(['set', '--map', '12', '--ticket', '205', '--title', 'Old', '--step', 'verify', '--auto', 'true'], { cwd: dir });
102
+ run(['set', '--ticket', '300', '--step', 'discuss'], { cwd: dir });
103
+ const s = readState(dir);
104
+ ok('new ticket: no carry-over of title/map/auto', s.ticket === 300 && s.title === null && s.map === null && s.auto === false);
105
+ }
106
+
107
+ // --- none map ----------------------------------------------------------------
108
+ {
109
+ const dir = mktmp();
110
+ run(['set', '--map', 'none', '--ticket', '7', '--title', 'W', '--step', 'discuss'], { cwd: dir });
111
+ ok('map none -> null', readState(dir).map === null);
112
+ }
113
+
114
+ // --- validation failures: exit 2, nothing written ----------------------------
115
+ for (const argv of [
116
+ ['set', '--ticket', '0', '--step', 'plan'],
117
+ ['set', '--ticket', 'abc', '--step', 'plan'],
118
+ ['set', '--ticket', '1', '--step', 'nope'],
119
+ ['set', '--ticket', '1', '--step', 'plan', '--map', '-3'],
120
+ ['set', '--ticket', '1', '--step', 'plan', '--map', 'x'],
121
+ ['set', '--ticket', '1', '--step', 'plan', '--auto', 'maybe'],
122
+ ['set', '--ticket', '1', '--step', 'plan', '--auto-done', '-1'],
123
+ ['set', '--ticket', '1', '--step', 'plan', '--auto-done', '01'],
124
+ ['set', '--ticket', '1', '--step', 'plan', '--auto-done', '1.5'],
125
+ ['set', '--ticket', '1', '--step', 'plan', '--auto-done', 'x'],
126
+ ['set', '--ticket', '1', '--step', 'plan', '--bogus', 'x'],
127
+ ['set', '--ticket', '1', '--step'],
128
+ ['set', '--ticket', '1'],
129
+ ['set', '--step', 'plan'],
130
+ ['frobnicate'],
131
+ [],
132
+ ]) {
133
+ const dir = mktmp();
134
+ const r = run([...argv.slice(0, 1), '--repo', dir, ...argv.slice(1)], { cwd: dir });
135
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2);
136
+ ok(`usage error writes stderr: ${argv.join(' ')}`, r.err.length > 0);
137
+ ok(`usage error writes nothing: ${argv.join(' ')}`, !fs.existsSync(path.join(dir, '.trailhead')));
138
+ }
139
+
140
+ // --- title sanitising --------------------------------------------------------
141
+ {
142
+ const dir = mktmp();
143
+ run(['set', '--ticket', '1', '--step', 'plan', '--title', ' a\nb\tc\u0000d '], { cwd: dir });
144
+ ok('title: control chars collapsed to spaces and trimmed', readState(dir).title === 'a b c d');
145
+ run(['set', '--ticket', '2', '--step', 'plan', '--title', 'x'.repeat(500)], { cwd: dir });
146
+ ok('title: capped at 200', readState(dir).title.length === 200);
147
+ }
148
+
149
+ // --- autoDone ----------------------------------------------------------------
150
+ {
151
+ const dir = mktmp();
152
+ run(['set', '--ticket', '1', '--step', 'plan'], { cwd: dir });
153
+ ok('autoDone: defaults to 0', readState(dir).autoDone === 0);
154
+ ok('autoDone: parseArgs strict integer', parseArgs(['set', '--auto-done', '0']).patch.autoDone === 0
155
+ && parseArgs(['set', '--auto-done', '12']).patch.autoDone === 12);
156
+ run(['set', '--auto', 'true', '--auto-done', '3'], { cwd: dir });
157
+ ok('autoDone: set with auto true', readState(dir).auto === true && readState(dir).autoDone === 3);
158
+ run(['set', '--step', 'execute'], { cwd: dir });
159
+ ok('autoDone: kept by a merge', readState(dir).autoDone === 3 && readState(dir).auto === true);
160
+ run(['set', '--auto', 'false'], { cwd: dir });
161
+ ok('autoDone: reset to 0 when auto goes false', readState(dir).autoDone === 0);
162
+ run(['set', '--auto-done', '5'], { cwd: dir });
163
+ ok('autoDone: ignored (0) while auto is false', readState(dir).autoDone === 0);
164
+ run(['set', '--auto', 'true', '--auto-done', '2'], { cwd: dir });
165
+ run(['set', '--ticket', '9', '--step', 'discuss', '--auto', 'true'], { cwd: dir });
166
+ ok('autoDone: dropped by a ticket change unless passed again', readState(dir).autoDone === 0);
167
+ run(['set', '--ticket', '10', '--step', 'discuss', '--auto', 'true', '--auto-done', '4'], { cwd: dir });
168
+ ok('autoDone: kept on a ticket change when passed again', readState(dir).autoDone === 4);
169
+ // old file without the field
170
+ const old = mktmp();
171
+ fs.mkdirSync(path.join(old, '.trailhead'));
172
+ fs.writeFileSync(statePath(old), JSON.stringify({ map: null, ticket: 4, title: 'x', step: 'plan', auto: true, session: null, updatedAt: 'z' }));
173
+ run(['set', '--step', 'execute'], { cwd: old });
174
+ ok('autoDone: old file without the field gets 0', readState(old).autoDone === 0 && readState(old).auto === true);
175
+ ok('autoDone: sanitizeState accepts non-negative integers only',
176
+ sanitizeState({ autoDone: 0 }).autoDone === 0 && sanitizeState({ autoDone: 7 }).autoDone === 7
177
+ && sanitizeState({ autoDone: -1 }).autoDone === undefined && sanitizeState({ autoDone: 1.5 }).autoDone === undefined
178
+ && sanitizeState({ autoDone: '3' }).autoDone === undefined);
179
+ }
180
+
181
+ // --- corrupt and malformed existing file -------------------------------------
182
+ {
183
+ const dir = mktmp();
184
+ fs.mkdirSync(path.join(dir, '.trailhead'));
185
+ fs.writeFileSync(statePath(dir), '{not json');
186
+ const r = run(['set', '--ticket', '5', '--step', 'plan'], { cwd: dir });
187
+ ok('corrupt file: overwritten, no crash', r.code === 0 && readState(dir).ticket === 5);
188
+ }
189
+ {
190
+ const dir = mktmp();
191
+ fs.mkdirSync(path.join(dir, '.trailhead'));
192
+ fs.writeFileSync(statePath(dir), JSON.stringify({ map: 'zz', ticket: 9, title: 42, step: 'plan', auto: 'yes', session: 7 }));
193
+ const r = run(['set', '--step', 'execute'], { cwd: dir });
194
+ const s = readState(dir);
195
+ ok('malformed fields dropped, valid kept', r.code === 0 && s.ticket === 9 && s.step === 'execute');
196
+ ok('malformed fields normalised', s.map === null && s.title === null && s.auto === false);
197
+ }
198
+ {
199
+ const dir = mktmp();
200
+ fs.mkdirSync(path.join(dir, '.trailhead'));
201
+ fs.writeFileSync(statePath(dir), JSON.stringify([1, 2]));
202
+ const r = run(['set', '--step', 'execute'], { cwd: dir });
203
+ ok('array file is treated as empty -> usage error without ticket', r.code === 2);
204
+ }
205
+
206
+ // --- clear -------------------------------------------------------------------
207
+ {
208
+ const dir = mktmp();
209
+ run(['set', '--ticket', '1', '--step', 'plan'], { cwd: dir });
210
+ const r = run(['clear'], { cwd: dir });
211
+ ok('clear present: cleared, file gone', r.code === 0 && r.lines[0].status === 'cleared' && !fs.existsSync(statePath(dir)));
212
+ const r2 = run(['clear', '--repo', dir], { cwd: '/' });
213
+ ok('clear absent: absent, exit 0', r2.code === 0 && r2.lines[0].status === 'absent');
214
+ }
215
+
216
+ // --- pure functions ----------------------------------------------------------
217
+ {
218
+ ok('sanitizeState: non-object -> {}', JSON.stringify(sanitizeState(null)) === '{}' && JSON.stringify(sanitizeState('x')) === '{}');
219
+ const m = mergeState({ ticket: 1, step: 'plan', title: 'a', map: 3, auto: false }, { step: 'verify' });
220
+ ok('mergeState: merges', m.ticket === 1 && m.step === 'verify' && m.title === 'a' && m.map === 3);
221
+ const p = parseArgs(['set', '--map', 'none']);
222
+ ok('parseArgs: map none -> null', p.patch.map === null);
223
+ }
224
+
225
+ // --- fault injection ---------------------------------------------------------
226
+ for (const failing of ['writeFileSync', 'renameSync']) {
227
+ const dir = mktmp();
228
+ run(['set', '--ticket', '1', '--step', 'plan', '--title', 'keep'], { cwd: dir });
229
+ const bytes = fs.readFileSync(statePath(dir));
230
+ const broken = { ...fs, [failing]: () => { throw new Error('boom ' + failing); } };
231
+ // a broken writeFileSync still writes a partial before throwing
232
+ if (failing === 'writeFileSync') {
233
+ broken.writeFileSync = (p, d) => { fs.writeFileSync(p, 'partial'); throw new Error('boom'); };
234
+ }
235
+ const r = run(['set', '--step', 'execute'], { cwd: dir, fsMod: broken });
236
+ ok(`failure ${failing}: exit 0, status skipped with message`, r.code === 0 && r.lines[0].status === 'skipped' && /boom/.test(r.lines[0].message));
237
+ ok(`failure ${failing}: previous file byte-identical`, Buffer.compare(fs.readFileSync(statePath(dir)), bytes) === 0);
238
+ ok(`failure ${failing}: no temp left`, JSON.stringify(trailheadFiles(dir)) === JSON.stringify(['work-state.json']));
239
+ }
240
+
241
+ // --- end-to-end --------------------------------------------------------------
242
+ {
243
+ const dir = mktmp();
244
+ const env = { ...process.env, CLAUDE_CODE_SESSION_ID: 'e2e' };
245
+ const out = execFileSync('node', [scriptPath, 'set', '--repo', dir, '--map', '1', '--ticket', '2', '--title', 'E', '--step', 'discuss'], { env, encoding: 'utf8' });
246
+ ok('e2e: one JSON line', JSON.parse(out.trim()).status === 'written');
247
+ ok('e2e: session stamped', readState(dir).session === 'e2e');
248
+ const bad = spawnSync('node', [scriptPath, 'set', '--repo', dir, '--step', 'nope'], { encoding: 'utf8' });
249
+ ok('e2e: usage error exits 2', bad.status === 2);
250
+ }
251
+
252
+ console.log(`work-state.test.js: ${passed} checks passed`);
@@ -0,0 +1,187 @@
1
+ # trailhead mod (contributor note)
2
+
3
+ `register.tsx` is the trailhead mod: a TypeScript hooks module that Claude Code loads
4
+ next to the command hooks. It shows the ticket in play, read from `.trailhead/work-state.json`
5
+ (written by the skills through `templates/trailhead-work-state.js`; the mod only reads it).
6
+ The typed contract lives in `plugins/trailhead/types/index.d.ts`, wired through the
7
+ `types` key of `plugin.json`. The module is declared in `hooks/hooks.json` under
8
+ `"modules"` (exactly one path, relative to `hooks.json`). The pure parts (parsing the file,
9
+ the status text, the pipeline stages) are in `work-state-view.ts`. The map pane lives in
10
+ `map-pane.tsx` (hooks and drawing) and `map-pane-view.ts` (pure parts); see "Map panel" below.
11
+
12
+ ## What it shows
13
+
14
+ - **Status entry**: one line under the prompt, `map #198 · #203 Title · plan` (the engine prefixes it with the plugin name, `trailhead:`)
15
+ (`whiteboard` instead of `map #<n>` for a ticket with no map; the title is left out when
16
+ there is none). During an autonomous run ` · auto 3 done` is appended.
17
+ - **Band above the prompt**: row one is the ticket number (bold, accent) and the title cut to
18
+ the band width with `…`; row two is the pipeline `discuss › plan › execute › verify`
19
+ (`✓` done in green, `▸` current in bold accent, pending dim). Step `resolve` shows all four
20
+ done, step `paused` shows all four dim followed by `‖ paused`. During an autonomous run
21
+ `auto · <n> done` sits at the right end of row two.
22
+ - **Orphaned**: when the file names another session than the current one (a `/clear`, a
23
+ resume, a new window), the entry gets ` · orphaned, resume?` and the band is drawn dim with
24
+ `orphaned, resume?` after the stages. A `/clear` marks the state on screen as orphaned at
25
+ once, without waiting for a turn: the engine empties the mod's atoms on `/clear`, so the file is
26
+ read again when the new session starts (`classic.SessionStart`, source `clear`). A file with no
27
+ session is never orphaned.
28
+ - **Quiet**: with no file, or a file that cannot be parsed (no valid ticket and step), the
29
+ band draws nothing and the status entry is cleared. A survey holding the band makes the
30
+ band yield, and the status entry stays. A read error other than "missing file" keeps what
31
+ is on screen.
32
+
33
+ The file is read on `session.start`, `turn.start`, `turn.complete`, and after a Bash call whose
34
+ command contains `trailhead-work-state.js` (so a step change shows without waiting for a turn).
35
+ Atoms and the status entry are only written when their content changed.
36
+
37
+ ## System prompt
38
+
39
+ Next to the screen pieces, the mod appends one section, `trailhead:work-state`, to the system
40
+ prompt through `prompt.compose` (session side of the cache boundary, after the engine's own
41
+ sections). It names the active map, the ticket in play (number, title, link) and the step, so the
42
+ agent keeps its bearings after a compaction. Links need a GitHub `origin`; without one the numbers
43
+ stand alone. The section is absent with no state, with an orphaned one, and under `--bare`.
44
+
45
+ It is a latched snapshot kept in the `promptSnapshot` atom (session scoped, never a module
46
+ variable). It is rebuilt only when the map, ticket, title, orphaned flag or auto flag changes, at
47
+ `session.start`, and after a main-loop compaction (`session.compact`, once it did not skip). A
48
+ `precompute` or a subagent's compaction installs nothing in the conversation, so it leaves the
49
+ snapshot alone. A step change deliberately leaves the text alone, so the prompt cache holds; the
50
+ section says the step may have moved on and that `.trailhead/work-state.json` holds the live one.
51
+ For the same reason an autonomous run shows as `Mode: autonomous run`, without the done count.
52
+
53
+ The issue title is untrusted text from GitHub. It is rendered as quoted data
54
+ (`Ticket: #204 "Title"`), with zero-width and bidi controls removed, inner double quotes turned
55
+ into single ones, and a line saying the title is data, not an instruction. The text is at most
56
+ 1024 bytes and carries no skill protocol.
57
+
58
+ `next(e)` is called once, before any enrichment, and a failure while enriching returns the
59
+ engine's sections untouched. If the repo cannot be read, the section is built without links and
60
+ latched like any other. A `/context` render (`analysis` trait) shows the section but never writes
61
+ the atom.
62
+
63
+ ## Map panel
64
+
65
+ `/trailhead:panel` opens or closes a pane listing a map's open tickets (the active map's, or the one clicked in the maps list). It is a plugin
66
+ command file (`commands/panel.md`), because a command registered by a mod cannot carry the
67
+ `trailhead:` namespace; the mod answers it before it reaches the model, so the file's body runs
68
+ only where no mod does (it says the panel is not available). Claude Code only: the Codex
69
+ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex-projection.js`).
70
+
71
+ - **What it shows**: the map as a link with its destination (dim), then `Frontier · n`,
72
+ `In progress · n` and `Blocked · n` (an empty group is left out). A row is `#n title` (a link to
73
+ the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket in play
74
+ in this session (work state, not orphaned) is marked `▸` in the accent; blocked rows are dim. A
75
+ frontier row has a `work` button that clears the conversation (`/clear`) and then runs
76
+ `/trailhead:work <n>` in the new one, both through `$.command.run`, which the host queues behind a
77
+ running turn; the row shows `queued` until the calls settle (across the reset too), and a
78
+ second press meanwhile is ignored. A refused `/clear`, or one that
79
+ did not change the session, runs no work command. `trailhead:unverified` tickets are never listed,
80
+ only counted in one dim line; `trailhead:whiteboard` tickets are dropped. The footer is a `refresh`
81
+ button and `updated <age>`, or `⚠ <reason> · list from <age>` after a `gh` failure (the last good list is kept;
82
+ reasons: `gh not installed`, `gh not authenticated (run gh auth login)`,
83
+ `offline: cannot reach GitHub`, `gh failed (exit <n>)`), and a `close` button at the right edge that
84
+ closes the panel. A status too long for the room beside the buttons moves to a row of its own under them. Titles come from GitHub and are untrusted:
85
+ flattened, stripped of control, zero-width and bidi characters, only drawn. Other states: `Loading tickets…`
86
+ on the first fetch, `No active map. Run /trailhead:map to pick one.`, `No open tickets. Fog may remain: /trailhead:map`.
87
+ - **Maps list and viewing**: with more than one open map (`gh issue list --label trailhead:map --state open`),
88
+ a `Maps · n` group sits under the header: one row per map, sorted by number, `#n title` cut to the width.
89
+ The active map has `●` in the accent and an `active` aside; the viewed map is bold; every other row is a
90
+ `Button`. A press shows that map's tickets, burn-down and `work` buttons (browse); it never writes
91
+ `.trailhead/active-map`. Pressing the active map's row, or `back to active` (in the dim line
92
+ `viewing #n · active #m` shown while another map is viewed), goes back. A `work` press on a viewed
93
+ map's ticket runs `/trailhead:work <n>` as always: the work verb makes the ticket's map the active one
94
+ (its `trailhead:map-<n>` label), and at the next `turn.complete` the pane sees the active map change
95
+ and follows (the view resets to the active map). The view also resets when the viewed map leaves a
96
+ freshly read open list (it closed). A single open map shows no group, as before. With no active
97
+ map the message stays and the open maps are listed under it, so one can be viewed (`viewing #n` then has a
98
+ `back` button). The maps read is the 4th of a refresh; a failure of it alone keeps the last list and sets no
99
+ footer error. Each refresh captures its target (viewed, else active) once and rechecks it before every state
100
+ write, so a refresh in flight when the view changes never commits the old map's tickets.
101
+ - **Burn-down**: below the groups, a `Burn-down` section shows the shown map's open-ticket count
102
+ over time, from its first ticket to now. A dim line gives `open <o> · done <d> · removed <r> · peak <p>`
103
+ (`removed` = closed with `trailhead:superseded` or `trailhead:out-of-scope`; `done` = other closed
104
+ tickets). The chart has one sample per pane column. On the terminal surface it is a `Raster` (3 rows
105
+ of eighth blocks, accent colour, key `burndown`); elsewhere a one-row text sparkline (`▁`..`█`).
106
+ The axis line has the start on the left (`Sep 18`, or `today 09:05` under a day, in UTC) and
107
+ `now` on the right, but `now` only while the history is fresh: when the history read failed alone, or
108
+ the whole refresh failed, the last curve is kept, drawn up to the time it was read, and the right label
109
+ shows its age (`3m ago`). The section is hidden with no active map, while loading, or when the history
110
+ is empty or has not been read yet.
111
+ - **Data**: no new storage. One more `gh issue list --label trailhead:map-<map> --state all
112
+ --limit 5000 --json number,labels,createdAt,closedAt`, run with the other two reads in the same
113
+ refresh (same timeout, same drop when the pane closed or the map changed). `trailhead:unverified`
114
+ and `trailhead:whiteboard` rows are skipped.
115
+ - **Failure**: a failed map or open-list read keeps the whole last good data, chart included. A
116
+ failed or unreadable history read alone keeps the last good history only when it belongs to the
117
+ same map (after a map switch no chart is drawn), still accepts the fresh list, and draws the
118
+ axis end as `<age> ago` instead of `now`. The footer stays about the list.
119
+ - **Cap**: `gh` returns the newest tickets first, so a history that reaches `HISTORY_LIMIT`
120
+ misses the oldest; the section then adds `history capped at 5000: starts at the oldest ticket read`.
121
+ - **Through `/clear`**: the engine empties the mod's atoms. At `session.end` with reason `clear` the pane
122
+ notes the auto-open flag, the viewed map and the active map; at `classic.SessionStart` (source `clear`)
123
+ it puts the flag back (a pane the person closed is not opened again), reads the active map, puts the
124
+ view back only when the active map is still the one it was set against, and refreshes at once, past
125
+ the floor. The pane is filled again with no turn.
126
+ - **Links**: the map and the rows are `Link`s, clickable where the terminal takes OSC 8 links. A
127
+ terminal Claude Code cannot identify (for example `TERM=xterm-256color` with no `TERM_PROGRAM`)
128
+ gets the engine's fallback instead: the text, then the URL in dim on the next line, nothing to
129
+ click. Start Claude Code with `FORCE_HYPERLINK=1` there to get real links.
130
+ - **Where the data comes from**: `.trailhead/active-map` (read locally on `session.start` and
131
+ `turn.complete`, whether or not the pane is open), then `gh issue view <map>` and
132
+ `gh issue list --label trailhead:map-<map> --state open` (plus the history read under "Burn-down" and the maps list read) through `$.process.run`,
133
+ where `<map>` is the viewed map, else the active one. A result is dropped if the pane closed or the shown map changed while `gh` ran.
134
+ - **When it refreshes**: only while the pane is open (a closed pane costs no `gh` call). Opening it,
135
+ `session.start`, `turn.complete`, a Bash call that runs `gh issue edit|close|create|comment|reopen`,
136
+ the `refresh` button, and a slow timer started on open. Never two fetches inside `MIN_REFRESH_MS`:
137
+ a request inside the floor keeps one trailing refresh for the rest of it.
138
+ - **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
139
+ when a map is active. Never on the main screen, never again after the pane was opened or closed
140
+ 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.
141
+ - **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
142
+ `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`,
143
+ `PANEL_COMMAND = 'trailhead:panel'`, `MAPS_LIMIT = 100` (not exported); `map-pane-view.ts`: `LIST_LIMIT = 200`, `HISTORY_LIMIT = 5000`.
144
+ - **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `pendingWork`, `paneAutoOpened`. Module variables hold the queue and
145
+ the timers, what `/clear` carries over (`carried`) and the `work` press in flight (`workInFlight`),
146
+ because `/clear` empties the atoms while they are needed.
147
+ - **Validator note**: `claude plugin validate` follows `$` only inside one file and refuses two
148
+ hooks on one event with no matcher. So `map-pane.tsx` registers `session.start`, `turn.complete`
149
+ and `tool.call` with matchers that always hold (or narrow: the `gh issue` write), next to the
150
+ bare hooks of `register.tsx`.
151
+
152
+ ## Requirements
153
+
154
+ - Claude Code 2.1.287 or newer (tested on 2.1.296).
155
+ - Command hooks stay the floor. A build without mod support skips only this module;
156
+ every command hook keeps working.
157
+ - npm delivery is not done yet: `bin/trailhead.js` does not ship the mod, and the
158
+ Codex projection never sees it. Only the plugin (`--plugin-dir`, marketplace) loads it.
159
+
160
+ ## Dev loop
161
+
162
+ Run from the repo root.
163
+
164
+ - Hot reload: `claude --plugin-dir plugins/trailhead` (or set `CLAUDE_CODE_PLUGIN_DIRS`) in an
165
+ interactive session. Saving `register.tsx` reloads the module.
166
+ - Validate the manifest, types and module: `claude plugin validate plugins/trailhead`.
167
+ It lists the hooked events and the declared state.
168
+ - Run the mod tests (every `*.test.ts` under the plugin): `claude plugin test plugins/trailhead`.
169
+ Tests live in `plugins/trailhead/tests/`. The test `on` hooks stand for the engine: they answer
170
+ `fs.read`, `session.id` and `ui.status`, and the UI tests mount the band and the pane on `terminal` and `desktop`.
171
+ The map pane tests also answer `process.run` (the `gh` runs), `ui.open`, `ui.close`, `ui.panes`, `command.run`
172
+ and `tool.call`, and use `mock.clock` for the floor and the timers. The test `$` cannot raise `ui.open`, so a test
173
+ that has the person open the pane sets the engine's pane list directly.
174
+ A hook result is `{ value }` or `{ deny }`; a `$.fs.read` that must fail is a `{ deny: 'ENOENT: ...' }`.
175
+ A `Text` carries no `key`, so the module keys a `Box` around each one and tests read the Text inside it.
176
+ - Everything at once: `npm test`. It runs the `*.test.js` suites, then validate and test.
177
+ Without `claude` on PATH it fails; set `TRAILHEAD_SKIP_MOD_CHECKS=1` to skip the mod checks.
178
+ - Debugging: `claude --debug`, or `claude -p --plugin-dir plugins/trailhead --debug-file <file> "say ok"`.
179
+ A healthy load logs `hooks module trailhead@inline loaded (...); events: session.start,classic.SessionStart,turn.complete,tool.call,command.run,ui.render,ui.close,prompt.compose,session.compact,turn.start,session.end`.
180
+ A module that fails to parse shows `hooks module did not load`.
181
+
182
+ ## Optional type check
183
+
184
+ An interactive `--plugin-dir` load writes `.claude-plugin/types/` and an engine
185
+ `tsconfig.json` into the plugin (both are git-ignored). After that:
186
+
187
+ npx -p typescript@5.9.3 tsc -p plugins/trailhead