@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.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- 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
|
-
- **
|
|
23
|
-
resume, a new window), the
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
session
|
|
26
|
+
`auto · <n> done · <m> in flight` sits at the right end of row two.
|
|
27
|
+
- **Another session / orphaned**: when the file names another session than the current one (a
|
|
28
|
+
resume, a new window, a parallel session), the ticket is not this session's, so it is not in
|
|
29
|
+
play and not in the system prompt. By default it reads neutrally: the entry gets
|
|
30
|
+
` · in another session` and the band keeps its normal look with `in another session` after
|
|
31
|
+
the stages, no resume hint. It reads ` · orphaned, resume?` (band drawn dim with
|
|
32
|
+
`orphaned, resume?` after the stages) only when the Claude Code session registry confirms the
|
|
33
|
+
session is gone: the registry is `<config dir>/sessions/<pid>.json` (`sessionId`, `pid`; the
|
|
34
|
+
config dir is `$CLAUDE_CONFIG_DIR`, else `~/.claude`), and a session is gone when no entry names
|
|
35
|
+
its id with a live pid. The pid is checked through `/proc` where it exists (Linux); elsewhere the
|
|
36
|
+
entry's presence counts as alive. A registry that cannot be listed or read, has unparseable
|
|
37
|
+
files, or is empty falls back to the neutral wording, never orphaned. A `/clear` marks the state
|
|
38
|
+
on screen as orphaned at once, without the registry and without waiting for a turn: the engine
|
|
39
|
+
empties the mod's atoms on `/clear`, so the file is read again when the new session starts
|
|
40
|
+
(`classic.SessionStart`, source `clear`). A file with no session is never orphaned.
|
|
28
41
|
- **Quiet**: with no file, or a file that cannot be parsed (no valid ticket and step), the
|
|
29
42
|
band draws nothing and the status entry is cleared. A survey holding the band makes the
|
|
30
43
|
band yield, and the status entry stays. A read error other than "missing file" keeps what
|
|
@@ -39,24 +52,39 @@ Atoms and the status entry are only written when their content changed.
|
|
|
39
52
|
Next to the screen pieces, the mod appends one section, `trailhead:work-state`, to the system
|
|
40
53
|
prompt through `prompt.compose` (session side of the cache boundary, after the engine's own
|
|
41
54
|
sections). It names the active map, the ticket in play (number, title, link) and the step, so the
|
|
42
|
-
agent keeps its bearings after a compaction. Links
|
|
43
|
-
stand alone. The section is absent with no state, with
|
|
55
|
+
agent keeps its bearings after a compaction. Links follow the pinned repo (its host included, see "Which repo"), so a shell `cd` out of it keeps
|
|
56
|
+
them; with no pin the numbers stand alone. The section is absent with no state, with a foreign one (orphaned or in another session), and under `--bare`.
|
|
44
57
|
|
|
45
|
-
It is a latched snapshot kept in the `promptSnapshot` atom (session scoped
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
It is a latched snapshot kept in the `promptSnapshot` atom (session scoped): what the model has
|
|
59
|
+
been told. It is reset at `session.start`, after a main-loop compaction (`session.compact`, once it
|
|
60
|
+
did not skip) and on `/clear`; only the first persisting compose after a reset writes it. A later
|
|
61
|
+
compose never rebuilds it: it returns the latched text, so the system prompt stays what the engine
|
|
62
|
+
already holds (the engine composes every turn but keeps sending the first one). Only `prompt.submit`
|
|
63
|
+
advances the latch after that, when the map, ticket, title, orphaned flag or auto flag changed. A
|
|
48
64
|
`precompute` or a subagent's compaction installs nothing in the conversation, so it leaves the
|
|
49
65
|
snapshot alone. A step change deliberately leaves the text alone, so the prompt cache holds; the
|
|
50
66
|
section says the step may have moved on and that `.trailhead/work-state.json` holds the live one.
|
|
51
67
|
For the same reason an autonomous run shows as `Mode: autonomous run`, without the done count.
|
|
52
68
|
|
|
69
|
+
The engine composes the system prompt once per process (first request) and keeps it; only a session
|
|
70
|
+
start, a main-loop compaction and `/clear` compose again. So a change made mid-session (the first
|
|
71
|
+
`trailhead-work-state.js set`, a new ticket, the state cleared) does not reach the model through
|
|
72
|
+
`prompt.compose`. A `prompt.submit` hook treats the latched snapshot as what the model has been told:
|
|
73
|
+
when the live key differs, it attaches the new section as `context` on that prompt, once per change
|
|
74
|
+
(overlapping prompts attach it once), and latches it; a prompt dropped beneath puts the latch back, so
|
|
75
|
+
the next one carries the change, as does one whose hook failed beneath or whose latch write was denied
|
|
76
|
+
(the latch stays where it was and the next prompt retries). A per-session in-memory mirror of the latch,
|
|
77
|
+
set only after a successful atom write, lets overlapping prompts agree on it. If the state went away it attaches a one-line note that the earlier section is
|
|
78
|
+
stale. With no compose yet it does nothing (the first compose carries the state), and it skips slash
|
|
79
|
+
commands and fails open: the prompt is never dropped.
|
|
80
|
+
|
|
53
81
|
The issue title is untrusted text from GitHub. It is rendered as quoted data
|
|
54
82
|
(`Ticket: #204 "Title"`), with zero-width and bidi controls removed, inner double quotes turned
|
|
55
83
|
into single ones, and a line saying the title is data, not an instruction. The text is at most
|
|
56
84
|
1024 bytes and carries no skill protocol.
|
|
57
85
|
|
|
58
86
|
`next(e)` is called once, before any enrichment, and a failure while enriching returns the
|
|
59
|
-
engine's sections untouched.
|
|
87
|
+
engine's sections untouched. With no pin the section is built without links and
|
|
60
88
|
latched like any other. A `/context` render (`analysis` trait) shows the section but never writes
|
|
61
89
|
the atom.
|
|
62
90
|
|
|
@@ -68,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`
|
|
73
|
-
the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
|
|
74
|
-
in this session
|
|
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
|
|
77
|
-
running turn
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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`)
|
|
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
|
|
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
|
+
}
|