@marcomigozzi/trailhead 0.15.0 → 0.16.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 +45 -9
- package/README.md +46 -10
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +5 -5
- package/bin/lib/codex-projection.test.js +2 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/mod/README.md +180 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- 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 +2 -2
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-unblock.js. Run: node unblock.test.js
|
|
3
|
+
// No framework: plain asserts, same style as needs-you.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. main() against a fake tracker behind an injected `gh` (multi-page reads included).
|
|
7
|
+
// 2. Usage errors (exit 2).
|
|
8
|
+
// 3. End-to-end: the script itself with a fake `gh` on PATH.
|
|
9
|
+
const assert = require('assert');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
|
|
15
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-unblock.js');
|
|
16
|
+
const { main } = require(scriptPath);
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
20
|
+
|
|
21
|
+
// --- fake tracker ------------------------------------------------------------
|
|
22
|
+
// dependents: [{number, state, labels}] of the closed issue (paginated output is the
|
|
23
|
+
// concatenation of one JSON object per line, whatever the page size).
|
|
24
|
+
// blockers: { [dependent]: [{number, state}] }. failOn: blocking | blocked_by | edit.
|
|
25
|
+
function makeTracker(init = {}) {
|
|
26
|
+
const t = { dependents: init.dependents || [], blockers: init.blockers || {}, failOn: init.failOn || null, badJson: !!init.badJson, calls: [], edits: [] };
|
|
27
|
+
const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
|
|
28
|
+
const lines = (list) => list.map((o) => JSON.stringify(o)).join('\n') + (list.length ? '\n' : '');
|
|
29
|
+
t.gh = (args) => {
|
|
30
|
+
t.calls.push(args.join(' '));
|
|
31
|
+
if (args[0] === 'api') {
|
|
32
|
+
const url = args.find((a) => a.startsWith('repos/'));
|
|
33
|
+
const m = /issues\/(\d+)\/dependencies\/(blocking|blocked_by)$/.exec(url || '');
|
|
34
|
+
if (!m) return res('', 1);
|
|
35
|
+
if (t.failOn === m[2]) return res('', 1);
|
|
36
|
+
if (t.badJson) return res('not json\n');
|
|
37
|
+
if (m[2] === 'blocking') return res(lines(t.dependents));
|
|
38
|
+
return res(lines(t.blockers[m[1]] || []));
|
|
39
|
+
}
|
|
40
|
+
if (args[0] === 'issue' && args[1] === 'edit') {
|
|
41
|
+
if (t.failOn === 'edit') return res('', 1);
|
|
42
|
+
t.edits.push(args.join(' '));
|
|
43
|
+
const n = Number(args[2]);
|
|
44
|
+
const d = t.dependents.find((x) => x.number === n);
|
|
45
|
+
if (d) d.labels = d.labels.filter((l) => l !== args[args.indexOf('--remove-label') + 1]);
|
|
46
|
+
return res('');
|
|
47
|
+
}
|
|
48
|
+
return res('', 1);
|
|
49
|
+
};
|
|
50
|
+
return t;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function run(t, argv, extra = {}) {
|
|
54
|
+
let out = '';
|
|
55
|
+
let errOut = '';
|
|
56
|
+
const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
|
|
57
|
+
let json = null;
|
|
58
|
+
try { json = JSON.parse(out.trim()); } catch { /* not json */ }
|
|
59
|
+
return { code, out, json, errOut };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const args = ['--issue', '5', '--repo', 'o/r'];
|
|
63
|
+
const B = 'trailhead:blocked';
|
|
64
|
+
|
|
65
|
+
// --- last blocker closed -> label removed ------------------------------------
|
|
66
|
+
{
|
|
67
|
+
const t = makeTracker({
|
|
68
|
+
dependents: [{ number: 8, state: 'open', labels: [B, 'x'] }],
|
|
69
|
+
blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'closed' }] },
|
|
70
|
+
});
|
|
71
|
+
const r = run(t, args);
|
|
72
|
+
ok('unblock: exits 0 with status ok', r.code === 0 && r.json.status === 'ok');
|
|
73
|
+
ok('unblock: the dependent is listed', JSON.stringify(r.json.unblocked) === '[8]' && r.json.stillBlocked.length === 0);
|
|
74
|
+
ok('unblock: the blocked label is removed', t.edits.length === 1 && t.edits[0].includes('--remove-label trailhead:blocked') && t.edits[0].includes('-R o/r'));
|
|
75
|
+
ok('unblock: the reads are paginated', t.calls.filter((c) => c.startsWith('api')).every((c) => c.includes('--paginate')));
|
|
76
|
+
const again = run(t, args);
|
|
77
|
+
ok('unblock: a retry is idempotent (label already gone)', again.code === 0 && again.json.unblocked.length === 0 && t.edits.length === 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- another blocker still open ----------------------------------------------
|
|
81
|
+
{
|
|
82
|
+
const t = makeTracker({
|
|
83
|
+
dependents: [{ number: 8, state: 'open', labels: [B] }],
|
|
84
|
+
blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'open' }, { number: 7, state: 'open' }] },
|
|
85
|
+
});
|
|
86
|
+
const r = run(t, args);
|
|
87
|
+
ok('unblock: an open blocker keeps the label', r.code === 0 && r.json.unblocked.length === 0 && t.edits.length === 0);
|
|
88
|
+
ok('unblock: stillBlocked names the open blockers', r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8 && JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[6,7]');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- skipped dependents -------------------------------------------------------
|
|
92
|
+
{
|
|
93
|
+
const t = makeTracker({
|
|
94
|
+
dependents: [
|
|
95
|
+
{ number: 8, state: 'open', labels: [] },
|
|
96
|
+
{ number: 9, state: 'closed', labels: [B] },
|
|
97
|
+
{ number: 10, state: 'open', labels: [B] },
|
|
98
|
+
],
|
|
99
|
+
blockers: { 10: [{ number: 5, state: 'closed' }] },
|
|
100
|
+
});
|
|
101
|
+
const r = run(t, args);
|
|
102
|
+
ok('unblock: a dependent without the label is skipped', !t.calls.some((c) => c.includes('issues/8/')));
|
|
103
|
+
ok('unblock: a closed dependent is skipped', !t.calls.some((c) => c.includes('issues/9/')));
|
|
104
|
+
ok('unblock: only the open labelled dependent is unblocked', JSON.stringify(r.json.unblocked) === '[10]');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- no dependents ------------------------------------------------------------
|
|
108
|
+
{
|
|
109
|
+
const r = run(makeTracker(), args);
|
|
110
|
+
ok('unblock: no dependents is ok and empty', r.code === 0 && r.json.status === 'ok' && r.json.unblocked.length === 0 && r.json.stillBlocked.length === 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- gh failures --------------------------------------------------------------
|
|
114
|
+
{
|
|
115
|
+
const t = makeTracker({ failOn: 'blocking' });
|
|
116
|
+
const r = run(t, args);
|
|
117
|
+
ok('unblock: a failed dependents read is an error', r.code === 1 && r.json.status === 'error' && /dependents|blocking/.test(r.json.reason));
|
|
118
|
+
ok('unblock: nothing edited after a failed read', t.edits.length === 0);
|
|
119
|
+
}
|
|
120
|
+
{
|
|
121
|
+
const t = makeTracker({
|
|
122
|
+
dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
|
|
123
|
+
blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
|
|
124
|
+
failOn: 'blocked_by',
|
|
125
|
+
});
|
|
126
|
+
const r = run(t, args);
|
|
127
|
+
ok('unblock: a failed blockers read is an error naming the step', r.code === 1 && r.json.status === 'error' && /blocked_by|blockers/.test(r.json.reason));
|
|
128
|
+
ok('unblock: nothing further edited after the failure', t.edits.length === 0 && t.calls.filter((c) => c.includes('issues/9/')).length === 0);
|
|
129
|
+
}
|
|
130
|
+
{
|
|
131
|
+
const t = makeTracker({
|
|
132
|
+
dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
|
|
133
|
+
blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
|
|
134
|
+
failOn: 'edit',
|
|
135
|
+
});
|
|
136
|
+
const r = run(t, args);
|
|
137
|
+
ok('unblock: a failed label removal is an error', r.code === 1 && r.json.status === 'error' && /remove/.test(r.json.reason));
|
|
138
|
+
ok('unblock: the next dependent is not touched after a failed edit', t.calls.filter((c) => c.startsWith('issue edit')).length === 1);
|
|
139
|
+
}
|
|
140
|
+
{
|
|
141
|
+
const r = run(makeTracker({ badJson: true }), args);
|
|
142
|
+
ok('unblock: unparseable gh output is an error', r.code === 1 && r.json.status === 'error' && /unparseable/.test(r.json.reason));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- multi-page reads ---------------------------------------------------------
|
|
146
|
+
// gh api --paginate with --jq prints one object per line across every page, so a
|
|
147
|
+
// second page is just more lines; the fake returns them concatenated, and the helper
|
|
148
|
+
// must read all of them (two pages of dependents, an open blocker only on page two).
|
|
149
|
+
{
|
|
150
|
+
const t = makeTracker({
|
|
151
|
+
dependents: [
|
|
152
|
+
...Array.from({ length: 30 }, (_, i) => ({ number: 100 + i, state: 'closed', labels: [] })),
|
|
153
|
+
{ number: 8, state: 'open', labels: [B] },
|
|
154
|
+
],
|
|
155
|
+
blockers: {
|
|
156
|
+
8: [...Array.from({ length: 30 }, (_, i) => ({ number: 200 + i, state: 'closed' })), { number: 99, state: 'open' }],
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const r = run(t, args);
|
|
160
|
+
ok('unblock: a dependent on the second page is found', r.code === 0 && r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8);
|
|
161
|
+
ok('unblock: an open blocker only on the second page keeps the label', JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[99]' && t.edits.length === 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- usage errors -------------------------------------------------------------
|
|
165
|
+
{
|
|
166
|
+
const t = makeTracker();
|
|
167
|
+
for (const [name, argv] of [
|
|
168
|
+
['no arguments', []],
|
|
169
|
+
['missing --repo', ['--issue', '5']],
|
|
170
|
+
['missing --issue', ['--repo', 'o/r']],
|
|
171
|
+
['bad --issue', ['--issue', 'x', '--repo', 'o/r']],
|
|
172
|
+
['bad --repo', ['--issue', '5', '--repo', 'nope']],
|
|
173
|
+
['unknown flag', ['--issue', '5', '--repo', 'o/r', '--force']],
|
|
174
|
+
['flag without value', ['--issue']],
|
|
175
|
+
]) {
|
|
176
|
+
const r = run(t, argv);
|
|
177
|
+
ok(`usage: ${name} exits 2 with stderr`, r.code === 2 && r.errOut.startsWith('usage:') && r.out === '');
|
|
178
|
+
}
|
|
179
|
+
ok('usage: no gh call on a usage error', t.calls.length === 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- end-to-end: the script with a fake gh on PATH -----------------------------
|
|
183
|
+
{
|
|
184
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'unblock-e2e-')));
|
|
185
|
+
const bin = path.join(dir, 'gh');
|
|
186
|
+
fs.writeFileSync(bin, [
|
|
187
|
+
'#!/bin/sh',
|
|
188
|
+
'case "$*" in',
|
|
189
|
+
' *dependencies/blocking*) echo \'{"number":8,"state":"open","labels":["trailhead:blocked"]}\' ;;',
|
|
190
|
+
' *dependencies/blocked_by*) echo \'{"number":5,"state":"closed"}\' ;;',
|
|
191
|
+
' *"issue edit"*) echo "$*" >> "' + path.join(dir, 'edits.log') + '" ;;',
|
|
192
|
+
' *) exit 1 ;;',
|
|
193
|
+
'esac',
|
|
194
|
+
'',
|
|
195
|
+
].join('\n'));
|
|
196
|
+
fs.chmodSync(bin, 0o755);
|
|
197
|
+
const r = spawnSync('node', [scriptPath, '--issue', '5', '--repo', 'o/r'], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } });
|
|
198
|
+
let j = null;
|
|
199
|
+
try { j = JSON.parse(r.stdout.trim()); } catch { /* not json */ }
|
|
200
|
+
ok('e2e: exits 0 with one JSON line', r.status === 0 && j && j.status === 'ok' && JSON.stringify(j.unblocked) === '[8]');
|
|
201
|
+
ok('e2e: the label edit reached gh', fs.readFileSync(path.join(dir, 'edits.log'), 'utf8').includes('--remove-label trailhead:blocked'));
|
|
202
|
+
const bad = spawnSync('node', [scriptPath], { encoding: 'utf8' });
|
|
203
|
+
ok('e2e: a usage error exits 2', bad.status === 2);
|
|
204
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log(`unblock.test.js: ${passed} checks passed`);
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
`register.tsx` is the trailhead mod: a TypeScript hooks module that Claude Code loads
|
|
4
4
|
next to the command hooks. It shows the ticket in play, read from `.trailhead/work-state.json`
|
|
5
5
|
(written by the skills through `templates/trailhead-work-state.js`; the mod only reads it).
|
|
6
|
+
The engine writes it in the ticket's own workspace, so when the pinned checkout has none the mod looks in the
|
|
7
|
+
repo's other checkouts (also when the pinned one holds another session's state; `git worktree list`, sibling `<repo>-t<n>` clones) and adopts the one naming this session
|
|
8
|
+
(`work-state-locate.ts`). After a `/clear` a state in another checkout is not shown (it names the old session),
|
|
9
|
+
unlike the own checkout's orphaned display.
|
|
6
10
|
The typed contract lives in `plugins/trailhead/types/index.d.ts`, wired through the
|
|
7
11
|
`types` key of `plugin.json`. The module is declared in `hooks/hooks.json` under
|
|
8
12
|
`"modules"` (exactly one path, relative to `hooks.json`). The pure parts (parsing the file,
|
|
@@ -13,18 +17,27 @@ the status text, the pipeline stages) are in `work-state-view.ts`. The map pane
|
|
|
13
17
|
|
|
14
18
|
- **Status entry**: one line under the prompt, `map #198 · #203 Title · plan` (the engine prefixes it with the plugin name, `trailhead:`)
|
|
15
19
|
(`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
|
|
20
|
+
there is none). During an autonomous run ` · auto 3 done · 2 in flight` is appended (see
|
|
21
|
+
[Autonomous run](#autonomous-run)).
|
|
17
22
|
- **Band above the prompt**: row one is the ticket number (bold, accent) and the title cut to
|
|
18
23
|
the band width with `…`; row two is the pipeline `discuss › plan › execute › verify`
|
|
19
24
|
(`✓` done in green, `▸` current in bold accent, pending dim). Step `resolve` shows all four
|
|
20
25
|
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
|
-
- **
|
|
23
|
-
resume, a new window), the
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
session
|
|
26
|
+
`auto · <n> done · <m> in flight` sits at the right end of row two.
|
|
27
|
+
- **Another session / orphaned**: when the file names another session than the current one (a
|
|
28
|
+
resume, a new window, a parallel session), the ticket is not this session's, so it is not in
|
|
29
|
+
play and not in the system prompt. By default it reads neutrally: the entry gets
|
|
30
|
+
` · in another session` and the band keeps its normal look with `in another session` after
|
|
31
|
+
the stages, no resume hint. It reads ` · orphaned, resume?` (band drawn dim with
|
|
32
|
+
`orphaned, resume?` after the stages) only when the Claude Code session registry confirms the
|
|
33
|
+
session is gone: the registry is `<config dir>/sessions/<pid>.json` (`sessionId`, `pid`; the
|
|
34
|
+
config dir is `$CLAUDE_CONFIG_DIR`, else `~/.claude`), and a session is gone when no entry names
|
|
35
|
+
its id with a live pid. The pid is checked through `/proc` where it exists (Linux); elsewhere the
|
|
36
|
+
entry's presence counts as alive. A registry that cannot be listed or read, has unparseable
|
|
37
|
+
files, or is empty falls back to the neutral wording, never orphaned. A `/clear` marks the state
|
|
38
|
+
on screen as orphaned at once, without the registry and without waiting for a turn: the engine
|
|
39
|
+
empties the mod's atoms on `/clear`, so the file is read again when the new session starts
|
|
40
|
+
(`classic.SessionStart`, source `clear`). A file with no session is never orphaned.
|
|
28
41
|
- **Quiet**: with no file, or a file that cannot be parsed (no valid ticket and step), the
|
|
29
42
|
band draws nothing and the status entry is cleared. A survey holding the band makes the
|
|
30
43
|
band yield, and the status entry stays. A read error other than "missing file" keeps what
|
|
@@ -39,24 +52,39 @@ Atoms and the status entry are only written when their content changed.
|
|
|
39
52
|
Next to the screen pieces, the mod appends one section, `trailhead:work-state`, to the system
|
|
40
53
|
prompt through `prompt.compose` (session side of the cache boundary, after the engine's own
|
|
41
54
|
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
|
|
43
|
-
stand alone. The section is absent with no state, with
|
|
55
|
+
agent keeps its bearings after a compaction. Links follow the pinned repo (its host included, see "Which repo"), so a shell `cd` out of it keeps
|
|
56
|
+
them; with no pin the numbers stand alone. The section is absent with no state, with a foreign one (orphaned or in another session), and under `--bare`.
|
|
44
57
|
|
|
45
|
-
It is a latched snapshot kept in the `promptSnapshot` atom (session scoped
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
It is a latched snapshot kept in the `promptSnapshot` atom (session scoped): what the model has
|
|
59
|
+
been told. It is reset at `session.start`, after a main-loop compaction (`session.compact`, once it
|
|
60
|
+
did not skip) and on `/clear`; only the first persisting compose after a reset writes it. A later
|
|
61
|
+
compose never rebuilds it: it returns the latched text, so the system prompt stays what the engine
|
|
62
|
+
already holds (the engine composes every turn but keeps sending the first one). Only `prompt.submit`
|
|
63
|
+
advances the latch after that, when the map, ticket, title, orphaned flag or auto flag changed. A
|
|
48
64
|
`precompute` or a subagent's compaction installs nothing in the conversation, so it leaves the
|
|
49
65
|
snapshot alone. A step change deliberately leaves the text alone, so the prompt cache holds; the
|
|
50
66
|
section says the step may have moved on and that `.trailhead/work-state.json` holds the live one.
|
|
51
67
|
For the same reason an autonomous run shows as `Mode: autonomous run`, without the done count.
|
|
52
68
|
|
|
69
|
+
The engine composes the system prompt once per process (first request) and keeps it; only a session
|
|
70
|
+
start, a main-loop compaction and `/clear` compose again. So a change made mid-session (the first
|
|
71
|
+
`trailhead-work-state.js set`, a new ticket, the state cleared) does not reach the model through
|
|
72
|
+
`prompt.compose`. A `prompt.submit` hook treats the latched snapshot as what the model has been told:
|
|
73
|
+
when the live key differs, it attaches the new section as `context` on that prompt, once per change
|
|
74
|
+
(overlapping prompts attach it once), and latches it; a prompt dropped beneath puts the latch back, so
|
|
75
|
+
the next one carries the change, as does one whose hook failed beneath or whose latch write was denied
|
|
76
|
+
(the latch stays where it was and the next prompt retries). A per-session in-memory mirror of the latch,
|
|
77
|
+
set only after a successful atom write, lets overlapping prompts agree on it. If the state went away it attaches a one-line note that the earlier section is
|
|
78
|
+
stale. With no compose yet it does nothing (the first compose carries the state), and it skips slash
|
|
79
|
+
commands and fails open: the prompt is never dropped.
|
|
80
|
+
|
|
53
81
|
The issue title is untrusted text from GitHub. It is rendered as quoted data
|
|
54
82
|
(`Ticket: #204 "Title"`), with zero-width and bidi controls removed, inner double quotes turned
|
|
55
83
|
into single ones, and a line saying the title is data, not an instruction. The text is at most
|
|
56
84
|
1024 bytes and carries no skill protocol.
|
|
57
85
|
|
|
58
86
|
`next(e)` is called once, before any enrichment, and a failure while enriching returns the
|
|
59
|
-
engine's sections untouched.
|
|
87
|
+
engine's sections untouched. With no pin the section is built without links and
|
|
60
88
|
latched like any other. A `/context` render (`analysis` trait) shows the section but never writes
|
|
61
89
|
the atom.
|
|
62
90
|
|
|
@@ -68,7 +96,7 @@ command file (`commands/panel.md`), because a command registered by a mod cannot
|
|
|
68
96
|
only where no mod does (it says the panel is not available). Claude Code only: the Codex
|
|
69
97
|
projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex-projection.js`).
|
|
70
98
|
|
|
71
|
-
- **What it shows**: the map as a link with its destination (dim), then `Frontier · n`,
|
|
99
|
+
- **What it shows**: the map as a link with its destination (dim), then `Needs you · n`, `Frontier · n`,
|
|
72
100
|
`In progress · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). A row is `#n title`
|
|
73
101
|
(a link to the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
|
|
74
102
|
in play in this session is marked `▸` in the accent and says `this session` in place of its holder;
|
|
@@ -76,18 +104,33 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
|
|
|
76
104
|
over the shared file (a state stamped with another session leaves it; a missing file clears it only
|
|
77
105
|
when the last state read was this session's, i.e. its own Resolve); with no own ticket the work
|
|
78
106
|
state decides, as before. A ticket carrying `trailhead:paused` (set by pause, cleared by resume or a
|
|
79
|
-
`work`/`quick` claim) shows once, under `Paused`, between In progress and Blocked, with
|
|
80
|
-
|
|
107
|
+
`work`/`quick` claim) shows once, under `Paused`, between In progress and Blocked, with a `resume`
|
|
108
|
+
button in place of its holder (it runs like `work`, with `/trailhead:resume <n>`, which checks the
|
|
109
|
+
claim); the label wins over `trailhead:blocked` and an assignee.
|
|
110
|
+
A ticket carrying `trailhead:needs-you` (an autonomous run parked it for the human; a `work`/`quick` claim
|
|
111
|
+
clears it) shows once, under `Needs you`, above Frontier. The label wins over paused, UAT, blocked and an
|
|
112
|
+
assignee. Any assignee is shown as its holder, never a button, even on a paused or blocked row. A blocked
|
|
113
|
+
row with no assignee is dim with no button. Any other row gets a `work` button, never `resume`, even when
|
|
114
|
+
paused: the claim clears the mark and resume would not. A
|
|
81
115
|
ticket carrying `trailhead:uat` (a pending UAT follow-up, a marker never a state) shows once, under `UAT`,
|
|
82
|
-
between In progress and Paused, whatever its state (`paused`
|
|
116
|
+
between In progress and Paused, whatever its state (`paused` and `needs-you` win), keeping its `work` button or holder, with a
|
|
83
117
|
dim `tests <parent title> #<n>` line under it. The parent comes from the `UAT of:` line of its body, read once
|
|
84
|
-
per refresh by `gh
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
per refresh by a paged `gh api graphql` read of `repository.issues(states: OPEN, labels: ["trailhead:uat"])` (100 a page,
|
|
119
|
+
up to 10 pages), filtered here to `trailhead:map-<n>` (`trailhead:whiteboard` on the whiteboard view). When a page fails
|
|
120
|
+
or is unreadable, or pages are left at the cap, a UAT row is drawn without its parent line (the rows still group by
|
|
121
|
+
label); never a partial map, no search fallback. A
|
|
87
122
|
frontier row has a `work` button that clears the conversation (`/clear`) and then runs
|
|
88
|
-
`/trailhead:work <n>` in the new one, both through `$.command.run
|
|
89
|
-
running turn
|
|
90
|
-
|
|
123
|
+
`/trailhead:work <n>` in the new one (a paused row's `resume` does the same with `/trailhead:resume <n>`), both through `$.command.run`.
|
|
124
|
+
A press during a running turn is held by the panel, which sends nothing to the host: the row shows `queued` with a
|
|
125
|
+
`cancel` button, and the press runs (`/clear`, then the verb) when the turn ends, an interrupted one included.
|
|
126
|
+
`cancel` drops it before anything is sent, so the conversation is untouched and the row goes back to its button.
|
|
127
|
+
With no turn running the press starts at once and the row reads `starting` (no cancel) until the calls settle,
|
|
128
|
+
across the reset too. One press at a time: another while one is held or starting is ignored. A session that ends
|
|
129
|
+
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
|
+
starts: the mod cannot move the session, so it appends the pinned checkout to the verb's args as `--in=<token>`
|
|
131
|
+
(base64url of the root) and the verb `cd`s there first. With no repo pinned, or a checkout that cannot be read
|
|
132
|
+
(a removed worktree included), the press is refused with a toast before the `/clear`; both are checked again when
|
|
133
|
+
a held press is sent. A refused `/clear`, or one that
|
|
91
134
|
did not change the session, runs no work command. `trailhead:unverified` tickets are never listed,
|
|
92
135
|
only counted in one dim line; `trailhead:whiteboard` tickets are dropped. The footer is a `refresh`
|
|
93
136
|
button and `updated <age>`, or `⚠ <reason> · list from <age>` after a `gh` failure (the last good list is kept;
|
|
@@ -120,16 +163,40 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
|
|
|
120
163
|
aliased `totalCount` connections on `trailhead:whiteboard`), never a per-ticket query. If that call
|
|
121
164
|
fails or its whiteboard fields are unreadable the row is drawn name-only, no counts and no error (the
|
|
122
165
|
`whiteboard` field of the `mapPane` atom is `null`; a failed read never keeps stale counts).
|
|
166
|
+
- **Total line**: one dim line, `<N> open across all maps + whiteboard`, directly under the `Whiteboard` row (in the map
|
|
167
|
+
view, its loading state and the no-active-map view) and under the header row in the Dashboard view. It is the sum of the
|
|
168
|
+
open issues labelled `trailhead:map-<n>` for every open map plus the open issues labelled `trailhead:whiteboard`: the
|
|
169
|
+
dashboard's own count scopes. Both labels are only ever applied to `trailhead:ticket` issues and a ticket carries one of
|
|
170
|
+
them, never both, so the label scopes are the ticket count and nothing is counted twice; closed maps never count,
|
|
171
|
+
whiteboard tickets always do. The source is one extra `gh api graphql` call after the maps read, with one aliased
|
|
172
|
+
`totalCount`-only connection per open map (at most `MAPS_LIMIT` = 100) plus one for the whiteboard, so it is exact and
|
|
173
|
+
has no list limit; the cost is up to one connection per enumerated map per refresh, only while the pane is open (15 s
|
|
174
|
+
floor). The result is the `openTotal` field of the `mapPane` atom, with the map numbers it was computed over. It is
|
|
175
|
+
hidden while unknown: before a first good count, when the count read fails or is unreadable and the open maps are not
|
|
176
|
+
the set the last good total covered (a failed read keeps the last total only over exactly the same maps, beside the
|
|
177
|
+
pane's own failure note), and whenever the maps read fell back to the name-only `gh issue list` (the count query runs
|
|
178
|
+
only on the GraphQL maps path), including when that fallback fails too and the last maps list is kept: with no maps
|
|
179
|
+
read there is no set to check the last total against, so it is hidden until the next good read. When more open maps exist than the maps read returned (`totalCount` above the nodes
|
|
180
|
+
read, so exactly 100 maps is not a cut) the total is a lower bound and reads `at least <N> open across all maps +
|
|
181
|
+
whiteboard`. In a narrow pane it shortens to `<N> open in total` (`≥<N> open in total` for a lower bound), and is left
|
|
182
|
+
out when even that does not fit.
|
|
123
183
|
- **Dashboard view**: the `Whiteboard` row (the header has no button for it) opens the repo index. It draws a `Dashboard` title (a link
|
|
124
184
|
to the pinned `trailhead:dashboard` issue when there is one) with a `map` button that goes back to the map
|
|
125
185
|
shown before (the view is kept), a `Maps · n` group with every open map pressable (a press leaves the
|
|
126
186
|
dashboard and views that map, the active one marked `●`), and a `Whiteboard · <n> open · <m> assigned` section (counted
|
|
127
187
|
over the listed tickets; `200+ open` when the list is capped): the open
|
|
128
|
-
`trailhead:ticket` + `trailhead:whiteboard` tickets as Frontier / In progress / UAT / Paused / Blocked, with the same rows,
|
|
188
|
+
`trailhead:ticket` + `trailhead:whiteboard` tickets as Needs you / Frontier / In progress / UAT / Paused / Blocked, with the same rows,
|
|
129
189
|
holders and `work` buttons (the work verb takes a whiteboard ticket as is). Unverified ones are counted only.
|
|
130
|
-
Its reads replace the map's in a refresh: the whiteboard list, `gh issue list --label trailhead:dashboard
|
|
190
|
+
Its reads replace the map's in a refresh: the whiteboard list (the GraphQL open read above), `gh issue list --label trailhead:dashboard
|
|
131
191
|
--json number,url` and the maps list; a failed list keeps the last one with the footer reason, a failed index
|
|
132
192
|
read alone keeps the last link. Data in the `dashboardPane` atom; the mode survives `/clear` like the view.
|
|
193
|
+
- **Following the whiteboard**: when this session takes a whiteboard ticket (its own work state names a ticket with no
|
|
194
|
+
map), the panel switches to the Dashboard view by itself, once per ticket. It looks at session start, at the end of a
|
|
195
|
+
turn and after a call to the work-state helper, always after the work state was re-read. Going back to the map by hand
|
|
196
|
+
is not undone (the followed ticket is remembered in a module variable, so a `/clear` does not forget it); a different
|
|
197
|
+
whiteboard ticket later is followed again. After a Resolve the panel stays on the whiteboard. A whiteboard ticket
|
|
198
|
+
created while a map is shown (`gh issue create` with the `trailhead:whiteboard` label) raises a toast,
|
|
199
|
+
`new whiteboard ticket #<n>: open Whiteboard`, instead of switching. The rules are pure, in `whiteboard-follow.ts`.
|
|
133
200
|
- **Burn-down**: below the groups, a `Burn-down` section shows the shown map's open-ticket count
|
|
134
201
|
over time, from its first ticket to now. A dim line gives `open <o> · done <d> · removed <r> · peak <p>`
|
|
135
202
|
(`removed` = closed with `trailhead:superseded` or `trailhead:out-of-scope`; `done` = other closed
|
|
@@ -161,26 +228,100 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
|
|
|
161
228
|
click. Start Claude Code with `FORCE_HYPERLINK=1` there to get real links.
|
|
162
229
|
- **Where the data comes from**: `.trailhead/active-map` (read locally on `session.start` and
|
|
163
230
|
`turn.complete`, whether or not the pane is open), then `gh issue view <map>` and
|
|
164
|
-
|
|
165
|
-
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.
|
|
231
|
+
the open list of `trailhead:map-<map>` (plus the history read under "Burn-down", the UAT parents and the maps list read)
|
|
232
|
+
through `$.process.run`, 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.
|
|
233
|
+
The open list (a map's, and the whiteboard's) is a `gh api graphql` read of `repository.issues(states: OPEN, labels: [...])`,
|
|
234
|
+
paged 100 a page up to 200 rows, because `gh issue list --label` goes through the search API, which lags a write by seconds:
|
|
235
|
+
a ticket just created or closed would show stale. GraphQL `labels` is any-of, so the whiteboard read filters
|
|
236
|
+
`trailhead:ticket` itself. A page that fails or is unreadable discards the whole GraphQL read (never a partial list)
|
|
237
|
+
and `gh issue list --label <label> --state open` runs once instead. The UAT-parent read joins the open list on
|
|
238
|
+
GraphQL (above), with no search fallback. Only the history (`--state all`) stays on `gh issue list` (search-backed): it can
|
|
239
|
+
trail a write by seconds, which the follow-up read below covers.
|
|
240
|
+
- **Which repo**: the one the session started in, pinned (`repo-pin.ts`) when `$.session.repo()` has a
|
|
241
|
+
remote of the form host/owner/name (github.com or an Enterprise host, any ssh/https form). The pinned root
|
|
242
|
+
is the checkout's own toplevel (`git rev-parse --show-toplevel` in the session's project root), so a
|
|
243
|
+
worktree session uses its worktree. It is checked again on each refresh and `turn.complete`. Every `gh`
|
|
244
|
+
call runs with the pinned root as `cwd` (`gh` resolves the host and the `{owner}`/`{repo}` placeholders
|
|
245
|
+
from it), and `.trailhead/active-map` and `.trailhead/work-state.json` are read by absolute path under
|
|
246
|
+
it, so a shell `cd` out of the repo changes nothing. A check outside any repo, or in one with no usable
|
|
247
|
+
remote, keeps the pin; a move into another repo moves it, drops the reads in flight and empties what the
|
|
248
|
+
pane held for the old one. With nothing ever pinned the pane says
|
|
249
|
+
`Not in a GitHub repository: open the session in a trailhead repo` and makes no `gh` call.
|
|
166
250
|
- **When it refreshes**: only while the pane is open (a closed pane costs no `gh` call). Opening it,
|
|
167
|
-
`session.start`, `turn.complete`, a Bash call that
|
|
168
|
-
|
|
169
|
-
|
|
251
|
+
`session.start`, `turn.complete`, a Bash call that writes to the tracker, the `refresh` button, and a slow
|
|
252
|
+
timer started on open. A write is `gh issue edit|close|create|comment|reopen|delete|transfer`, a `gh api` call on a
|
|
253
|
+
`repos/.../issues` path with a write method (`-X`/`--method` POST, PATCH, PUT, DELETE) or an implicit POST
|
|
254
|
+
(`-f`, `-F`, `--field`, `--raw-field`, `--input`) and no `-X GET`, or the `trailhead-issue-body.js` helper
|
|
255
|
+
(`GH_ISSUE_WRITE`); reads never refresh. Never two fetches inside `MIN_REFRESH_MS`: a request inside the floor
|
|
256
|
+
keeps one trailing refresh for the rest of it. A write that reads at once also arms that trailing refresh, so one
|
|
257
|
+
more read follows when the floor ends, after the search index (the history) caught up; a burst of writes still costs one
|
|
258
|
+
read per floor plus one trailing. The host drops a refused timer without a word (a refused `every` period ends
|
|
259
|
+
the interval), so the slow timer is kept with its due time: past it by more than `TIMER_GRACE_MS` it is held dead
|
|
260
|
+
and the next trigger starts it again (`isTimerLate`). A read asked while the pane is undrawn is dropped; a render
|
|
261
|
+
whose list (with no error) is older than `TIMER_MS + TIMER_GRACE_MS` asks for a read at once, as an empty view does.
|
|
170
262
|
- **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
|
|
171
263
|
when a map is active. Never on the main screen, never again after the pane was opened or closed
|
|
172
264
|
by the person (a press on the `close` button counts as closing it). The open runs from a zero-delay timer because a render may not write state.
|
|
265
|
+
Being unasked, it waits undrawn below 144 columns (`isPlaced: false`), typical of a fresh session; `/trailhead:panel`
|
|
266
|
+
then opens it again as the person's request (drawn at any width) instead of closing it, and closes it only when even
|
|
267
|
+
that leaves it undrawn (no surface places panes).
|
|
173
268
|
- **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
|
|
174
|
-
`MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`,
|
|
175
|
-
`PANEL_COMMAND = 'trailhead:panel'`, `MAPS_LIMIT = 100` (not exported); `map-pane-view.ts`: `LIST_LIMIT = 200`, `HISTORY_LIMIT = 5000`.
|
|
176
|
-
- **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `isDashboard`, `dashboardPane`, `pendingWork`, `paneAutoOpened`. Module variables hold the queue and
|
|
177
|
-
the timers, what `/clear` carries over (`carried`)
|
|
178
|
-
because `/clear` empties the atoms while they are needed.
|
|
269
|
+
`MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`, `TIMER_GRACE_MS = 30_000`,
|
|
270
|
+
`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`.
|
|
271
|
+
- **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `isDashboard`, `dashboardPane`, `pendingWork`, `pendingPhase` (`queued` or `starting`), `paneAutoOpened`. Module variables hold the queue and
|
|
272
|
+
the timers, what `/clear` carries over (`carried`), the held press (`held`) and the one being sent (`inFlight`),
|
|
273
|
+
because `/clear` empties the atoms while they are needed. Whether a main turn runs is `turn-state.ts`, kept by
|
|
274
|
+
`register.tsx`'s bare `turn.start`, `turn.complete` and `session.end` hooks and read by the pane.
|
|
179
275
|
- **Validator note**: `claude plugin validate` follows `$` only inside one file and refuses two
|
|
180
|
-
hooks on one event with no matcher. So `map-pane.tsx` registers `session.start`, `turn.complete
|
|
181
|
-
and `tool.call` with matchers that always hold (or narrow: the
|
|
276
|
+
hooks on one event with no matcher. So `map-pane.tsx` registers `session.start`, `turn.complete`,
|
|
277
|
+
`session.end` and `tool.call` with matchers that always hold (or narrow: the tracker write), next to the
|
|
182
278
|
bare hooks of `register.tsx`.
|
|
183
279
|
|
|
280
|
+
## Autonomous run
|
|
281
|
+
|
|
282
|
+
The mod only watches an autonomous run (`/trailhead:auto`): no Stop hook, no `$.prompt.submit`
|
|
283
|
+
that drives, no write to a work state or to the tracker.
|
|
284
|
+
|
|
285
|
+
- **Run membership.** The run's main checkout is `$.session.repo().root` (the main working tree,
|
|
286
|
+
whatever linked worktree the session sits in); only when that is unreadable the pinned root
|
|
287
|
+
stands in. A checkout's work state belongs to the run when it says `auto: true` and the checkout is
|
|
288
|
+
(a) the main checkout or the pinned one, (b) a `git worktree` of the repo on branch
|
|
289
|
+
`trailhead/t<n>` whose state is for ticket `<n>`, or (c) a sibling directory `<repo>-t<n>` that is an
|
|
290
|
+
independent checkout (its git dir inside it) on `trailhead/t<n>`, with a state for ticket `<n>`. The
|
|
291
|
+
session in the state is ignored: slot states are sessionless on purpose. A symlinked alias of one
|
|
292
|
+
checkout is not recognised as the same path (the mod has no realpath).
|
|
293
|
+
- **Scan gate.** The scan runs only when a run can exist: `<main>/.trailhead/auto-run.json` is
|
|
294
|
+
present, or the own work state says `auto: true`. Otherwise the run is cleared at once and no process
|
|
295
|
+
runs, so a session outside a run costs one file read.
|
|
296
|
+
- **Label.** `auto · 3 done · 2 in flight · 1 needs you · 1 paused`: `done` is the highest
|
|
297
|
+
`autoDone` among the members, `in flight` counts the members whose step is not `paused`; zero
|
|
298
|
+
counts are left out. With a work state of its own the label sits at the right end of band row two and
|
|
299
|
+
after the step in the status; without one (the slots' states are sessionless) it is a one-line band and
|
|
300
|
+
the whole status.
|
|
301
|
+
- **Counts.** `needs you` and `paused` come from one GraphQL `gh api graphql` read with two searches
|
|
302
|
+
(open `trailhead:ticket` issues labelled `trailhead:needs-you` or `trailhead:paused`), narrowed by the
|
|
303
|
+
run record's scope (`map:<n>` to `trailhead:map-<n>`, `whiteboard` to `trailhead:whiteboard`,
|
|
304
|
+
`everything` or an unreadable record to the whole repo). They are scope counts, not run-only counts:
|
|
305
|
+
`paused` is not narrowed to the run's own tickets, and an earlier needs-you mark stays until the
|
|
306
|
+
person clears it. The read runs under the pinned root, only while a run is detected, never
|
|
307
|
+
faster than `MIN_REFRESH_MS`, and on the pin's own host (`--hostname`, so an Enterprise pin is searched there);
|
|
308
|
+
a failed read keeps the last counts; no pin, or no engine clock, means no call.
|
|
309
|
+
- **Bounded failures.** A scan that fails (the git listing, a read other than a missing file) keeps
|
|
310
|
+
the last run for at most `MAX_FAILURES = 3` failures in a row, then clears it; a success resets the count.
|
|
311
|
+
Failures are counted at most once per `MIN_REFRESH_MS` (a turn refreshes several times), so a brief git
|
|
312
|
+
hiccup never clears a live run; without a clock every failure counts.
|
|
313
|
+
A clean scan with no member clears the run at once.
|
|
314
|
+
- **Resume hint.** `auto interrupted · /trailhead:auto to resume` (in the status
|
|
315
|
+
`auto interrupted, /trailhead:auto to resume`) shows while a flag is set and a run member remains. The
|
|
316
|
+
flag is set by a main-loop `turn.complete` with `isAborted` (an Esc), and by an accepted main-conversation
|
|
317
|
+
compaction that lands while no main turn is running. A compaction inside a running turn sets nothing; if
|
|
318
|
+
that turn then ends aborted, the first rule applies. Subagent turns never touch it. It clears at the
|
|
319
|
+
next main-loop `turn.start` (a background slot's completion starts a new turn), when `/trailhead:auto`
|
|
320
|
+
is submitted and not dropped, and it is hidden once no member is left (hand-back).
|
|
321
|
+
- **State.** Atoms `autoRun`, `runCounts`, `autoInterrupted`; the module variables are the last run, the
|
|
322
|
+
failure count, the counts cache and the running-turn flag. The status is recomputed last in each
|
|
323
|
+
refresh, from all of them at once.
|
|
324
|
+
|
|
184
325
|
## Requirements
|
|
185
326
|
|
|
186
327
|
- Claude Code 2.1.287 or newer (tested on 2.1.296).
|
|
@@ -208,7 +349,7 @@ Run from the repo root.
|
|
|
208
349
|
- Everything at once: `npm test`. It runs the `*.test.js` suites, then validate and test.
|
|
209
350
|
Without `claude` on PATH it fails; set `TRAILHEAD_SKIP_MOD_CHECKS=1` to skip the mod checks.
|
|
210
351
|
- Debugging: `claude --debug`, or `claude -p --plugin-dir plugins/trailhead --debug-file <file> "say ok"`.
|
|
211
|
-
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`.
|
|
352
|
+
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,prompt.submit,session.compact,turn.start,session.end`.
|
|
212
353
|
A module that fails to parse shows `hooks module did not load`.
|
|
213
354
|
|
|
214
355
|
## Optional type check
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Pure view logic of the autonomous run in the mod: which checkouts belong to the run, the counts,
|
|
2
|
+
// the label and the resume hint. No engine calls here, so it is easy to test.
|
|
3
|
+
import type { AutoRun, RunCounts, WorkState } from '../../types'
|
|
4
|
+
import { MAX_FAILURES } from './worktree-porcelain'
|
|
5
|
+
|
|
6
|
+
export const RESUME_HINT = 'auto interrupted · /trailhead:auto to resume'
|
|
7
|
+
// Nello status il separatore è la virgola, perché il punto mediano separa già le parti.
|
|
8
|
+
export const RESUME_HINT_STATUS = 'auto interrupted, /trailhead:auto to resume'
|
|
9
|
+
|
|
10
|
+
const SLOT_BRANCH = /^refs\/heads\/trailhead\/t(\d+)$/
|
|
11
|
+
const SCOPE = /^(?:map:([1-9]\d*)|whiteboard|everything)$/
|
|
12
|
+
|
|
13
|
+
export type Where = { kind: 'main' } | { kind: 'worktree'; ticket: number } | { kind: 'clone'; ticket: number; onBranch: boolean }
|
|
14
|
+
|
|
15
|
+
// The ticket of a slot branch `refs/heads/trailhead/t<n>`; any other branch is no slot.
|
|
16
|
+
export const slotTicket = (branch: string | null): number | null => {
|
|
17
|
+
const m = branch === null ? null : SLOT_BRANCH.exec(branch)
|
|
18
|
+
const n = m === null ? 0 : Number(m[1])
|
|
19
|
+
return n > 0 ? n : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Whether a checkout's work state belongs to the run. The session is ignored on purpose: slot states are sessionless.
|
|
23
|
+
export const isRunMember = (where: Where, state: WorkState): boolean => {
|
|
24
|
+
if (state.auto !== true) return false
|
|
25
|
+
if (where.kind === 'main') return true
|
|
26
|
+
if (where.kind === 'clone' && !where.onBranch) return false
|
|
27
|
+
return state.ticket === where.ticket
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const aggregateRun = (states: readonly WorkState[]): AutoRun | null =>
|
|
31
|
+
states.length === 0
|
|
32
|
+
? null
|
|
33
|
+
: { done: Math.max(...states.map(s => s.autoDone)), inFlight: states.filter(s => s.step !== 'paused').length }
|
|
34
|
+
|
|
35
|
+
// The label of the record's scope; null = the whole repo (everything, absent or unreadable).
|
|
36
|
+
export const parseRunScope = (recordText: string): string | null => {
|
|
37
|
+
let raw: unknown
|
|
38
|
+
try {
|
|
39
|
+
raw = JSON.parse(recordText)
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
const scope = (raw as { scope?: unknown } | null)?.scope
|
|
44
|
+
const m = typeof scope === 'string' ? SCOPE.exec(scope) : null
|
|
45
|
+
if (m === null) return null
|
|
46
|
+
return m[1] !== undefined ? `trailhead:map-${m[1]}` : scope === 'whiteboard' ? 'trailhead:whiteboard' : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COUNTS_QUERY =
|
|
50
|
+
'query($needs: String!, $paused: String!) { needsYou: search(query: $needs, type: ISSUE, first: 1) { issueCount } paused: search(query: $paused, type: ISSUE, first: 1) { issueCount } }'
|
|
51
|
+
|
|
52
|
+
// One GraphQL read, two searches, on the pin's own host (an Enterprise pin is searched there). The slug is owner/name (repo-pin keeps it to safe characters).
|
|
53
|
+
export const runCountsArgs = (host: string, slug: string, scopeLabel: string | null): string[] => {
|
|
54
|
+
const base = `repo:${slug} is:open label:"trailhead:ticket"`
|
|
55
|
+
const scope = scopeLabel === null ? '' : ` label:"${scopeLabel}"`
|
|
56
|
+
return [
|
|
57
|
+
'api', 'graphql', '--hostname', host,
|
|
58
|
+
'-f', `query=${COUNTS_QUERY}`,
|
|
59
|
+
'-f', `needs=${base} label:"trailhead:needs-you"${scope}`,
|
|
60
|
+
'-f', `paused=${base} label:"trailhead:paused"${scope}`,
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const parseRunCounts = (stdout: string): RunCounts | null => {
|
|
65
|
+
let raw: unknown
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(stdout)
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
const data = (raw as { data?: Record<string, { issueCount?: unknown } | undefined> } | null)?.data
|
|
72
|
+
const needsYou = data?.needsYou?.issueCount
|
|
73
|
+
const paused = data?.paused?.issueCount
|
|
74
|
+
const ok = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0
|
|
75
|
+
return ok(needsYou) && ok(paused) ? { needsYou, paused } : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The parts after "auto": done, in flight (when known), then the counts that are not zero.
|
|
79
|
+
export const runParts = (run: { done: number; inFlight?: number }, counts: RunCounts | null): string[] => [
|
|
80
|
+
`${run.done} done`,
|
|
81
|
+
...(run.inFlight === undefined ? [] : [`${run.inFlight} in flight`]),
|
|
82
|
+
...(counts !== null && counts.needsYou > 0 ? [`${counts.needsYou} needs you`] : []),
|
|
83
|
+
...(counts !== null && counts.paused > 0 ? [`${counts.paused} paused`] : []),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
export const runLabel = (run: { done: number; inFlight?: number }, counts: RunCounts | null): string =>
|
|
87
|
+
['auto', ...runParts(run, counts)].join(' · ')
|
|
88
|
+
|
|
89
|
+
export const showsResumeHint = (flag: boolean, run: AutoRun | null): boolean => flag && run !== null
|
|
90
|
+
|
|
91
|
+
// Una scansione fallita tiene l'ultimo run, finché non arriva il MAX_FAILURES-esimo fallimento di fila.
|
|
92
|
+
export const nextRunAfterFailure = (prev: AutoRun | null, failures: number): AutoRun | null =>
|
|
93
|
+
failures >= MAX_FAILURES ? null : prev
|