@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.
- package/README.it.md +38 -13
- package/README.md +48 -13
- package/bin/lib/codex-projection.js +18 -11
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- 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
|