@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.
Files changed (96) hide show
  1. package/README.it.md +45 -9
  2. package/README.md +46 -10
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/grilling-holistic-review-docs.test.js +90 -0
  12. package/bin/lib/codex-projection.js +5 -5
  13. package/bin/lib/codex-projection.test.js +2 -2
  14. package/bin/needs-you-docs.test.js +41 -0
  15. package/bin/pick-stress-test-docs.test.js +105 -0
  16. package/bin/trailhead.js +3 -3
  17. package/bin/trailhead.test.js +17 -18
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/commands/auto.md +2 -2
  21. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  22. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  23. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  24. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  25. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  26. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  27. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  28. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  29. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  30. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  31. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  32. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  33. package/plugins/trailhead/hooks/mod/README.md +180 -39
  34. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  35. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  36. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  37. package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
  38. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  39. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  40. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  41. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  42. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  43. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  44. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  46. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  47. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  48. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  49. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  50. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  52. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  56. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  57. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  59. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  60. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  61. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  62. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  63. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  64. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  65. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  66. package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
  67. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  68. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  69. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  70. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  71. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  72. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  73. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  74. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  75. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  76. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  77. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  78. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  79. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  80. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  81. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  82. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  83. package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
  84. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  85. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  86. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  88. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  89. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  90. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  91. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  92. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  93. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  94. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  95. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  96. 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
- - **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.
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 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`.
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, 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
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. If the repo cannot be read, the section is built without links and
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 its holder
80
- when assigned and never a `work` button; the label wins over `trailhead:blocked` and an assignee. A
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` wins), keeping its `work` button or holder, with a
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 issue list --label trailhead:uat --label trailhead:map-<n>` (`--label trailhead:whiteboard`
85
- on the whiteboard view) `--json number,body`, capped like the ticket list at 200: beyond that, or when the read
86
- fails, a UAT row is drawn without its parent line (the rows still group by label). A
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`, which the host queues behind a
89
- running turn; the row shows `queued` until the calls settle (across the reset too), and a
90
- second press meanwhile is ignored. A refused `/clear`, or one that
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
- `gh issue list --label trailhead:map-<map> --state open` (plus the history read under "Burn-down" and the maps list read) through `$.process.run`,
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 runs `gh issue edit|close|create|comment|reopen`,
168
- the `refresh` button, and a slow timer started on open. Never two fetches inside `MIN_REFRESH_MS`:
169
- a request inside the floor keeps one trailing refresh for the rest of it.
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`) and the `work` press in flight (`workInFlight`),
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 `gh issue` write), next to 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