@marcomigozzi/trailhead 0.14.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  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/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -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,14 +96,41 @@ 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`,
72
- `In progress · n` and `Blocked · n` (an empty group is left out). A row is `#n title` (a link to
73
- the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket in play
74
- in this session (work state, not orphaned) is marked `▸` in the accent; blocked rows are dim. A
99
+ - **What it shows**: the map as a link with its destination (dim), then `Needs you · n`, `Frontier · n`,
100
+ `In progress · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). A row is `#n title`
101
+ (a link to the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
102
+ in play in this session is marked `▸` in the accent and says `this session` in place of its holder;
103
+ blocked rows are dim. The `ownTicket` atom keeps that mark when another session's work state takes
104
+ over the shared file (a state stamped with another session leaves it; a missing file clears it only
105
+ when the last state read was this session's, i.e. its own Resolve); with no own ticket the work
106
+ state decides, as before. A ticket carrying `trailhead:paused` (set by pause, cleared by resume or 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
115
+ ticket carrying `trailhead:uat` (a pending UAT follow-up, a marker never a state) shows once, under `UAT`,
116
+ between In progress and Paused, whatever its state (`paused` and `needs-you` win), keeping its `work` button or holder, with a
117
+ dim `tests <parent title> #<n>` line under it. The parent comes from the `UAT of:` line of its body, read once
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
75
122
  frontier row has a `work` button that clears the conversation (`/clear`) and then runs
76
- `/trailhead:work <n>` in the new one, both through `$.command.run`, which the host queues behind a
77
- running turn; the row shows `queued` until the calls settle (across the reset too), and a
78
- second press meanwhile is ignored. A refused `/clear`, or one that
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
79
134
  did not change the session, runs no work command. `trailhead:unverified` tickets are never listed,
80
135
  only counted in one dim line; `trailhead:whiteboard` tickets are dropped. The footer is a `refresh`
81
136
  button and `updated <age>`, or `⚠ <reason> · list from <age>` after a `gh` failure (the last good list is kept;
@@ -84,9 +139,12 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
84
139
  closes the panel. A status too long for the room beside the buttons moves to a row of its own under them. Titles come from GitHub and are untrusted:
85
140
  flattened, stripped of control, zero-width and bidi characters, only drawn. Other states: `Loading tickets…`
86
141
  on the first fetch, `No active map. Run /trailhead:map to pick one.`, `No open tickets. Fog may remain: /trailhead:map`.
87
- - **Maps list and viewing**: with more than one open map (`gh issue list --label trailhead:map --state open`),
142
+ - **Maps list and viewing**: with more than one open map (one `gh api graphql` call for the open
143
+ `trailhead:map` issues with their sub-issue counts, bounded at 100 maps, no pagination),
88
144
  a `Maps · n` group sits under the header: one row per map, sorted by number, `#n title` cut to the width.
89
- The active map has `●` in the accent and an `active` aside; the viewed map is bold; every other row is a
145
+ Each row ends with a dim `<open> open / <total>` count; if the call fails or is unreadable the pane falls
146
+ back to `gh issue list --label trailhead:map --state open` and draws the names only.
147
+ The active map has `●` in the accent and an `active` aside (after the counts, `7 open / 23 · active`); the viewed map is bold; every other row is a
90
148
  `Button`. A press shows that map's tickets, burn-down and `work` buttons (browse); it never writes
91
149
  `.trailhead/active-map`. Pressing the active map's row, or `back to active` (in the dim line
92
150
  `viewing #n · active #m` shown while another map is viewed), goes back. A `work` press on a viewed
@@ -98,6 +156,47 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
98
156
  `back` button). The maps read is the 4th of a refresh; a failure of it alone keeps the last list and sets no
99
157
  footer error. Each refresh captures its target (viewed, else active) once and rechecks it before every state
100
158
  write, so a refresh in flight when the view changes never commits the old map's tickets.
159
+ - **Whiteboard row**: above the Maps list (in the map view, its loading state and the no-active-map view,
160
+ even with a single map) a `Whiteboard` button switches the pane to the repo index, the `isDashboard` atom.
161
+ Beside the name it carries the whiteboard's counts in a map row's shape, dim: `<open> open / <total>`
162
+ (open whiteboard tickets over all of them). They come from the maps read's single GraphQL call (two
163
+ aliased `totalCount` connections on `trailhead:whiteboard`), never a per-ticket query. If that call
164
+ fails or its whiteboard fields are unreadable the row is drawn name-only, no counts and no error (the
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.
183
+ - **Dashboard view**: the `Whiteboard` row (the header has no button for it) opens the repo index. It draws a `Dashboard` title (a link
184
+ to the pinned `trailhead:dashboard` issue when there is one) with a `map` button that goes back to the map
185
+ shown before (the view is kept), a `Maps · n` group with every open map pressable (a press leaves the
186
+ dashboard and views that map, the active one marked `●`), and a `Whiteboard · <n> open · <m> assigned` section (counted
187
+ over the listed tickets; `200+ open` when the list is capped): the open
188
+ `trailhead:ticket` + `trailhead:whiteboard` tickets as Needs you / Frontier / In progress / UAT / Paused / Blocked, with the same rows,
189
+ holders and `work` buttons (the work verb takes a whiteboard ticket as is). Unverified ones are counted only.
190
+ Its reads replace the map's in a refresh: the whiteboard list (the GraphQL open read above), `gh issue list --label trailhead:dashboard
191
+ --json number,url` and the maps list; a failed list keeps the last one with the footer reason, a failed index
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`.
101
200
  - **Burn-down**: below the groups, a `Burn-down` section shows the shown map's open-ticket count
102
201
  over time, from its first ticket to now. A dim line gives `open <o> · done <d> · removed <r> · peak <p>`
103
202
  (`removed` = closed with `trailhead:superseded` or `trailhead:out-of-scope`; `done` = other closed
@@ -129,26 +228,100 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
129
228
  click. Start Claude Code with `FORCE_HYPERLINK=1` there to get real links.
130
229
  - **Where the data comes from**: `.trailhead/active-map` (read locally on `session.start` and
131
230
  `turn.complete`, whether or not the pane is open), then `gh issue view <map>` and
132
- `gh issue list --label trailhead:map-<map> --state open` (plus the history read under "Burn-down" and the maps list read) through `$.process.run`,
133
- where `<map>` is the viewed map, else the active one. A result is dropped if the pane closed or the shown map changed while `gh` ran.
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.
134
250
  - **When it refreshes**: only while the pane is open (a closed pane costs no `gh` call). Opening it,
135
- `session.start`, `turn.complete`, a Bash call that runs `gh issue edit|close|create|comment|reopen`,
136
- the `refresh` button, and a slow timer started on open. Never two fetches inside `MIN_REFRESH_MS`:
137
- a request inside the floor keeps one trailing refresh for the rest of it.
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.
138
262
  - **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
139
263
  when a map is active. Never on the main screen, never again after the pane was opened or closed
140
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).
141
268
  - **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
142
- `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`,
143
- `PANEL_COMMAND = 'trailhead:panel'`, `MAPS_LIMIT = 100` (not exported); `map-pane-view.ts`: `LIST_LIMIT = 200`, `HISTORY_LIMIT = 5000`.
144
- - **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `pendingWork`, `paneAutoOpened`. Module variables hold the queue and
145
- the timers, what `/clear` carries over (`carried`) and the `work` press in flight (`workInFlight`),
146
- because `/clear` empties the atoms while they are needed.
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.
147
275
  - **Validator note**: `claude plugin validate` follows `$` only inside one file and refuses two
148
- hooks on one event with no matcher. So `map-pane.tsx` registers `session.start`, `turn.complete`
149
- and `tool.call` with matchers that always hold (or narrow: the `gh issue` write), next to the
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
150
278
  bare hooks of `register.tsx`.
151
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
+
152
325
  ## Requirements
153
326
 
154
327
  - Claude Code 2.1.287 or newer (tested on 2.1.296).
@@ -176,7 +349,7 @@ Run from the repo root.
176
349
  - Everything at once: `npm test`. It runs the `*.test.js` suites, then validate and test.
177
350
  Without `claude` on PATH it fails; set `TRAILHEAD_SKIP_MOD_CHECKS=1` to skip the mod checks.
178
351
  - Debugging: `claude --debug`, or `claude -p --plugin-dir plugins/trailhead --debug-file <file> "say ok"`.
179
- A healthy load logs `hooks module trailhead@inline loaded (...); events: session.start,classic.SessionStart,turn.complete,tool.call,command.run,ui.render,ui.close,prompt.compose,session.compact,turn.start,session.end`.
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`.
180
353
  A module that fails to parse shows `hooks module did not load`.
181
354
 
182
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
@@ -0,0 +1,165 @@
1
+ // The autonomous run seen from the main checkout (#296): which slots are in flight and the scope counts.
2
+ // Read-only: the mod only watches, it never writes a work state or the tracker. Pure parts are in
3
+ // auto-run-view.ts; here the engine part that scans the checkouts and reads the counts.
4
+ import type { EngineInterface } from 'claude-code'
5
+
6
+ import type { AutoRun, RepoPin, RunCounts, WorkState } from '../../types'
7
+ import { GH_TIMEOUT_MS, MIN_REFRESH_MS } from './map-pane'
8
+ import { aggregateRun, isRunMember, nextRunAfterFailure, parseRunCounts, parseRunScope, runCountsArgs, slotTicket } from './auto-run-view'
9
+ import type { Where } from './auto-run-view'
10
+ import { pinKey } from './repo-pin'
11
+ import { listSiblings, listWorktreeBranches, readAt, trim } from './work-state-locate'
12
+ import { isMissing, parseWorkState } from './work-state-view'
13
+
14
+ const RECORD_PATH = '.trailhead/auto-run.json'
15
+ const GIT_TIMEOUT_MS = 5_000
16
+
17
+ // L'ultimo run noto e i fallimenti di fila (con l'istante dell'ultimo contato): sono macchinario del modulo, non stato disegnato.
18
+ let lastRun: AutoRun | null = null
19
+ let failures = 0
20
+ let lastFailureAt: number | null = null
21
+ let counts: { key: string; at: number; value: RunCounts | null } | null = null
22
+
23
+ export const resetAutoRun = (): void => {
24
+ lastRun = null
25
+ failures = 0
26
+ lastFailureAt = null
27
+ counts = null
28
+ }
29
+
30
+ // Il run sparisce: anche i conteggi e il loro pavimento.
31
+ export const clearRunCounts = (): void => {
32
+ counts = null
33
+ }
34
+
35
+ type Gate = { kind: 'closed' } | { kind: 'open'; scope: string | null } | { kind: 'failed' }
36
+
37
+ // Il run può esistere solo se c'è il record o lo stato proprio è auto: altrimenti nessun processo parte.
38
+ async function gate($: EngineInterface, main: string, own: WorkState | null): Promise<Gate> {
39
+ try {
40
+ const text = (await $.fs.read(`${trim(main)}/${RECORD_PATH}`)) as string
41
+ return { kind: 'open', scope: parseRunScope(text) }
42
+ } catch (err) {
43
+ if (!isMissing(err)) return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'failed' }
44
+ return own?.auto === true ? { kind: 'open', scope: null } : { kind: 'closed' }
45
+ }
46
+ }
47
+
48
+ // La regola di cloneOnBranch (trailhead-auto-slot.js): git dir dentro la cartella e HEAD sul branch dello slot.
49
+ // undefined: git non è riuscito a girare.
50
+ async function cloneOnBranch($: EngineInterface, dir: string, ticket: number): Promise<boolean | undefined> {
51
+ const git = (args: string[]) => $.process.run(['git', '-C', dir, ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS })
52
+ try {
53
+ const g = await git(['rev-parse', '--absolute-git-dir'])
54
+ if (g.exitCode !== 0) return false
55
+ const gitDir = trim(g.stdout.trim())
56
+ if (gitDir !== dir && !gitDir.startsWith(`${dir}/`)) return false
57
+ const b = await git(['symbolic-ref', '--quiet', 'HEAD'])
58
+ return b.exitCode === 0 && b.stdout.trim() === `refs/heads/trailhead/t${ticket}`
59
+ } catch {
60
+ return undefined
61
+ }
62
+ }
63
+
64
+ type Candidate = { path: string; where: Where | { kind: 'sibling'; ticket: number } }
65
+
66
+ // Un checkout alla volta, senza doppioni: vince la grafia di git, il main e il pin contano come main.
67
+ const candidatesOf = (main: string, pinned: string, listed: readonly { path: string; branch: string | null }[], siblings: readonly string[]): Candidate[] => {
68
+ const all: Candidate[] = [{ path: trim(main), where: { kind: 'main' } }]
69
+ if (trim(pinned) !== trim(main)) all.push({ path: trim(pinned), where: { kind: 'main' } })
70
+ for (const w of listed) {
71
+ const ticket = slotTicket(w.branch)
72
+ if (ticket !== null) all.push({ path: trim(w.path), where: { kind: 'worktree', ticket } })
73
+ }
74
+ for (const path of siblings) {
75
+ const ticket = Number(/-t(\d+)$/.exec(path)?.[1])
76
+ if (ticket > 0) all.push({ path: trim(path), where: { kind: 'sibling', ticket } })
77
+ }
78
+ return all.filter((c, i) => all.findIndex(o => o.path === c.path) === i)
79
+ }
80
+
81
+ type Scan = { ok: true; run: AutoRun | null } | { ok: false }
82
+
83
+ async function scan($: EngineInterface, main: string, pinned: string): Promise<Scan> {
84
+ const listed = await listWorktreeBranches($, main)
85
+ const siblings = await listSiblings($, main)
86
+ if (listed === null || siblings === null) return { ok: false }
87
+ const candidates = candidatesOf(main, pinned, listed, siblings)
88
+ const texts = await Promise.all(candidates.map(c => readAt($, c.path)))
89
+ if (texts.includes(undefined)) return { ok: false }
90
+ const members: WorkState[] = []
91
+ for (const [i, c] of candidates.entries()) {
92
+ const text = texts[i]
93
+ const state = typeof text === 'string' ? parseWorkState(text) : null
94
+ if (state === null || !state.auto) continue
95
+ let where: Where
96
+ if (c.where.kind === 'sibling') {
97
+ // Il controllo git costa due processi: solo per un fratello il cui stato è già auto del suo ticket.
98
+ if (state.ticket !== c.where.ticket) continue
99
+ const onBranch = await cloneOnBranch($, c.path, c.where.ticket)
100
+ if (onBranch === undefined) return { ok: false }
101
+ where = { kind: 'clone', ticket: c.where.ticket, onBranch }
102
+ } else where = c.where
103
+ if (isRunMember(where, state)) members.push(state)
104
+ }
105
+ return { ok: true, run: aggregateRun(members) }
106
+ }
107
+
108
+ // Un turno fa più refresh: un fallimento conta solo se dall'ultimo contato è passato MIN_REFRESH_MS,
109
+ // così un singhiozzo breve di git non azzera un run vivo. Senza orologio ogni fallimento conta.
110
+ async function isNewFailure($: EngineInterface): Promise<boolean> {
111
+ const now = await nowOf($)
112
+ if (now === null) return true
113
+ if (lastFailureAt !== null && now - lastFailureAt < MIN_REFRESH_MS) return false
114
+ lastFailureAt = now
115
+ return true
116
+ }
117
+
118
+ // Il run attuale e lo scope del record. Una scansione fallita tiene l'ultimo risultato per al massimo
119
+ // MAX_FAILURES volte di fila; un miss pulito o il gate chiuso lo azzerano subito.
120
+ export async function scanRun($: EngineInterface, input: { main: string; pinned: string; ownState: WorkState | null }): Promise<{ run: AutoRun | null; scope: string | null }> {
121
+ const g = await gate($, input.main, input.ownState)
122
+ if (g.kind === 'closed') {
123
+ lastRun = null
124
+ failures = 0
125
+ lastFailureAt = null
126
+ return { run: null, scope: null }
127
+ }
128
+ const result = g.kind === 'open' ? await scan($, trim(input.main), input.pinned) : ({ ok: false } as const)
129
+ if (result.ok) {
130
+ failures = 0
131
+ lastFailureAt = null
132
+ lastRun = result.run
133
+ } else if (await isNewFailure($)) {
134
+ failures += 1
135
+ lastRun = nextRunAfterFailure(lastRun, failures)
136
+ }
137
+ return { run: lastRun, scope: g.kind === 'open' ? g.scope : null }
138
+ }
139
+
140
+ async function nowOf($: EngineInterface): Promise<number | null> {
141
+ try {
142
+ return await $.clock.now()
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ // I conteggi dello scope, mai più spesso di MIN_REFRESH_MS; una lettura fallita tiene gli ultimi.
149
+ export async function readRunCounts($: EngineInterface, pin: RepoPin, scope: string | null): Promise<RunCounts | null> {
150
+ const key = `${pinKey(pin)}|${scope ?? ''}`
151
+ const now = await nowOf($)
152
+ const cached = counts !== null && counts.key === key ? counts : null
153
+ // Senza orologio non c'è pavimento: niente gh a ruota libera, vale il valore in cache (o nulla).
154
+ if (now === null) return cached?.value ?? null
155
+ if (cached !== null && now - cached.at < MIN_REFRESH_MS) return cached.value
156
+ let value = cached?.value ?? null
157
+ try {
158
+ const out = await $.process.run(['gh', ...runCountsArgs(pin.host, pin.slug, scope)], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
159
+ if (out.exitCode === 0) value = parseRunCounts(out.stdout) ?? value
160
+ } catch {
161
+ // Lettura fallita: restano gli ultimi conteggi.
162
+ }
163
+ counts = { key, at: now, value }
164
+ return value
165
+ }