@marcomigozzi/trailhead 0.17.0 → 0.18.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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ // Repo-location environment for git spawns.
3
+ //
4
+ // Git gives these variables precedence over `-C` and the cwd, so an exported
5
+ // GIT_DIR (a hook, `git rebase --exec`, a parent shell) silently redirects every
6
+ // git call to another repository. Each git spawn in the plugin builds its env
7
+ // through gitEnv() so it always acts on the checkout it was pointed at.
8
+ //
9
+ // The names are `git rev-parse --local-env-vars` minus GIT_CONFIG*: those carry
10
+ // the parent's `-c` options and are meant to be inherited. A static list keeps
11
+ // this free of a git spawn; refresh it from that command when git adds a name.
12
+ // Names must be deleted: an empty string is not "unset" (`fatal: '' is not a git repository`).
13
+ //
14
+ // hooks/lib/trailhead-git-env.js is a byte-identical copy for the hooks (they
15
+ // install without the templates); a test keeps the two in sync.
16
+
17
+ const GIT_LOCATION_VARS = Object.freeze([
18
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
19
+ 'GIT_OBJECT_DIRECTORY',
20
+ 'GIT_DIR',
21
+ 'GIT_WORK_TREE',
22
+ 'GIT_IMPLICIT_WORK_TREE',
23
+ 'GIT_GRAFT_FILE',
24
+ 'GIT_INDEX_FILE',
25
+ 'GIT_NO_REPLACE_OBJECTS',
26
+ 'GIT_REPLACE_REF_BASE',
27
+ 'GIT_PREFIX',
28
+ 'GIT_SHALLOW_FILE',
29
+ 'GIT_COMMON_DIR',
30
+ ]);
31
+
32
+ // A new env object: `base` without the repo-location names, then `extra` on top.
33
+ // Never mutates `base` (process.env by default).
34
+ function gitEnv(base = process.env, extra = {}) {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(base)) if (!GIT_LOCATION_VARS.includes(k)) out[k] = v;
37
+ return { ...out, ...extra };
38
+ }
39
+
40
+ module.exports = { GIT_LOCATION_VARS, gitEnv };
@@ -97,8 +97,12 @@ only where no mod does (it says the panel is not available). Claude Code only: t
97
97
  projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex-projection.js`).
98
98
 
99
99
  - **What it shows**: the map as a link with its destination (dim), then `In progress · n` (first, right under
100
- the maps list), `Needs you · n`, `Frontier · 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
100
+ the maps list), `Needs you · n`, `Frontier · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). Each group, the
101
+ maps list included, has its own glyph and colour (`◈` Maps violet, `▶` In progress accent, `!` Needs you orange, `◆` Frontier green,
102
+ `◎` UAT cyan, `‖` Paused grey, `■` Blocked red): the heading is the glyph, the bold name in that colour and a dim rule to the pane's edge, and
103
+ every ticket's block (its row and the dim lines under it) sits after a gutter in the same colour, so the groups tell apart without
104
+ colour too: in the terminal a `│ ` on each of its lines, on the desktop one bar as tall as the block; the lines under the row are indented past it. A row is `#n title`
105
+ (a link to the issue) and the type (`build` cyan, `decision` orange, any other dim; dim on a blocked row). The unverified-count line is amber. Every assigned row shows its holder as `@login`; the ticket
102
106
  in play in this session is marked `▸` in the accent and says `this session` in place of its holder;
103
107
  blocked rows are dim. `In progress` is global for you: after the shown scope's own claimed tickets it lists your
104
108
  open in-progress tickets (assigned to the `gh` login) of every other open map and of the whiteboard, each with a dim
@@ -141,7 +145,7 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
141
145
  did not change the session, runs no work command. `trailhead:unverified` tickets are never listed,
142
146
  only counted in one dim line; `trailhead:whiteboard` tickets are dropped. The footer is a `refresh`
143
147
  button and `updated <age>`, or `⚠ <reason> · list from <age>` after a `gh` failure (the last good list is kept;
144
- reasons: `gh not installed`, `gh not authenticated (run gh auth login)`,
148
+ reasons: `gh not installed`, `checkout not found: <path>` (the pinned folder is gone and no other checkout of the repo was found), `gh not authenticated (run gh auth login)`,
145
149
  `offline: cannot reach GitHub`, `gh failed (exit <n>)`), and a `close` button at the right edge that
146
150
  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:
147
151
  flattened, stripped of control, zero-width and bidi characters, only drawn. Other states: `Loading tickets…`
@@ -252,14 +256,17 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
252
256
  from it), and `.trailhead/active-map` and `.trailhead/work-state.json` are read by absolute path under
253
257
  it, so a shell `cd` out of the repo changes nothing. A check outside any repo, or in one with no usable
254
258
  remote, keeps the pin; a move into another repo moves it, drops the reads in flight and empties what the
255
- pane held for the old one. With nothing ever pinned the pane says
259
+ pane held for the old one. A pinned checkout deleted since (a finished worktree) moves the pin, silently, to a live checkout of
260
+ the same repo: the main working tree, else another `git worktree`, else a same-remote clone beside it (probes run as `git -C`
261
+ from `/`, retried at most once a minute). With nothing ever pinned the pane says
256
262
  `Not in a GitHub repository: open the session in a trailhead repo` and makes no `gh` call.
257
263
  - **When it refreshes**: only while the pane is open (a closed pane costs no `gh` call). Opening it,
258
264
  `session.start`, `turn.complete`, a Bash call that writes to the tracker, the `refresh` button, and a slow
259
265
  timer started on open. A write is `gh issue edit|close|create|comment|reopen|delete|transfer`, a `gh api` call on a
260
266
  `repos/.../issues` path with a write method (`-X`/`--method` POST, PATCH, PUT, DELETE) or an implicit POST
261
267
  (`-f`, `-F`, `--field`, `--raw-field`, `--input`) and no `-X GET`, or the `trailhead-issue-body.js` helper
262
- (`GH_ISSUE_WRITE`); reads never refresh. Never two fetches inside `MIN_REFRESH_MS` (save the turn-end read below): a request inside the floor
268
+ (`GH_ISSUE_WRITE`), outside any heredoc body (`isTrackerWrite`: a body is data, so a `gh issue close` line in a
269
+ `cat <<EOF` writes nothing); reads never refresh. Never two fetches inside `MIN_REFRESH_MS` (save the turn-end read below): a request inside the floor
263
270
  keeps one trailing refresh for the rest of it. A write that reads at once also arms that trailing refresh, so one
264
271
  more read follows when the floor ends, after the search index (the history) caught up; a burst of writes still costs one
265
272
  read per floor plus one trailing, and one more when the turn ends after it. The host drops a refused timer without a word (a refused `every` period ends
@@ -271,16 +278,25 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
271
278
  involved; it leaves an armed trailing refresh alone, so the read after the search index caught up still follows.
272
279
  A read asked while the pane is undrawn is dropped; a render
273
280
  whose list (with no error) is older than `TIMER_MS + TIMER_GRACE_MS` asks for a read at once, as an empty view does.
274
- - **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
275
- when a map is active. Never on the main screen, never again after the pane was opened or closed
276
- 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.
277
- Being unasked, it waits undrawn below 144 columns (`isPlaced: false`), typical of a fresh session; `/trailhead:panel`
278
- then opens it again as the person's request (drawn at any width) instead of closing it, and closes it only when even
279
- that leaves it undrawn (the attached surfaces place no panes). With no surface attached yet (`no-surface`, the start of a session)
280
- the pane stays open and the command answers that it will open once the screen is ready, again on every repeat; the
281
- pane's first render on the surface that attaches asks for its read.
281
+ - **Auto-open**: in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render, or in the
282
+ viewport of a `session.attach`, so a surface that attaches later tries again), when a map is active. Never on
283
+ the main screen, never again after the person acted on the pane (the command, closing it by hand, the `close`
284
+ button). The open runs from a zero-delay timer because a render may not write state.
285
+ The auto-open counts as done only once the pane is actually drawn (seen placed by a later check, or by the pane's
286
+ own render, which fires only when drawn). Being unasked, the open can stay undrawn (`isPlaced: false`): below
287
+ 144 columns, or on a surface that has not reported its layout yet, typical of a fresh session. That does not burn
288
+ the auto-open: while the pane is listed undrawn nothing opens it again (the host places it itself when it can),
289
+ and if it vanishes undrawn the next trigger opens it again. `/trailhead:panel` closes a pane only when it is
290
+ listed and drawn; a listed but undrawn pane is opened again as the person's request (drawn at any width), and if
291
+ even that leaves it undrawn the pane stays open and the command says so in a fixed sentence (`Map panel is open but not shown yet: ...`; the host's reason goes to the
292
+ debug log only), so it is never closed unseen. With no surface attached yet (`no-surface`, the start of a session) the command answers
293
+ that the panel will open once the screen is ready, again on every repeat; the pane's first render on the surface
294
+ that attaches asks for its read. A desktop session starts at its first message, not when it is created, so nothing
295
+ can open the pane on a session where nothing was typed, and a `/trailhead:panel` sent as that first message lands
296
+ right after the auto-open. Within `UNSEEN_MS` of a drawn auto-open the command keeps the pane and answers
297
+ `Map panel opened.` instead of closing a pane nobody could have seen.
282
298
  - **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
283
- `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`, `TIMER_GRACE_MS = 30_000`,
299
+ `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`, `TIMER_GRACE_MS = 30_000`, `UNSEEN_MS = 2_000`,
284
300
  `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`.
285
301
  - **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
286
302
  the timers, what `/clear` carries over (`carried`), the held press (`held`) and the one being sent (`inFlight`),
@@ -4,6 +4,7 @@
4
4
  import type { EngineInterface } from 'claude-code'
5
5
 
6
6
  import type { AutoRun, RepoPin, RunCounts, WorkState } from '../../types'
7
+ import { gitArgv } from './git-env'
7
8
  import { GH_TIMEOUT_MS, MIN_REFRESH_MS } from './map-pane'
8
9
  import { aggregateRun, isRunMember, nextRunAfterFailure, parseRunCounts, parseRunScope, runCountsArgs, slotTicket } from './auto-run-view'
9
10
  import type { Where } from './auto-run-view'
@@ -48,7 +49,7 @@ async function gate($: EngineInterface, main: string, own: WorkState | null): Pr
48
49
  // La regola di cloneOnBranch (trailhead-auto-slot.js): git dir dentro la cartella e HEAD sul branch dello slot.
49
50
  // undefined: git non è riuscito a girare.
50
51
  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
+ const git = (args: string[]) => $.process.run(gitArgv('-C', dir, ...args), { cwd: dir, timeoutMs: GIT_TIMEOUT_MS })
52
53
  try {
53
54
  const g = await git(['rev-parse', '--absolute-git-dir'])
54
55
  if (g.exitCode !== 0) return false
@@ -0,0 +1,31 @@
1
+ // Leaf without imports: git argv for the mod's process spawns.
2
+ //
3
+ // Git gives the repo-location variables precedence over `-C` and the cwd, and
4
+ // `$.process.run` can only add or override env, never delete. So an inherited
5
+ // GIT_DIR would redirect every git spawn: wrap the argv in `env -u NAME ...`.
6
+ // The list is templates/trailhead-git-env.js's GIT_LOCATION_VARS (git rev-parse
7
+ // --local-env-vars minus GIT_CONFIG*); hooks/lib/git-env-mod.test.js keeps them equal.
8
+ export const GIT_LOCATION_VARS: readonly string[] = [
9
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
10
+ 'GIT_OBJECT_DIRECTORY',
11
+ 'GIT_DIR',
12
+ 'GIT_WORK_TREE',
13
+ 'GIT_IMPLICIT_WORK_TREE',
14
+ 'GIT_GRAFT_FILE',
15
+ 'GIT_INDEX_FILE',
16
+ 'GIT_NO_REPLACE_OBJECTS',
17
+ 'GIT_REPLACE_REF_BASE',
18
+ 'GIT_PREFIX',
19
+ 'GIT_SHALLOW_FILE',
20
+ 'GIT_COMMON_DIR',
21
+ ]
22
+
23
+ // `argv` run with the repo-location variables removed from its env.
24
+ export const scrubGitEnv = (argv: readonly string[]): string[] => [
25
+ 'env',
26
+ ...GIT_LOCATION_VARS.flatMap(name => ['-u', name]),
27
+ ...argv,
28
+ ]
29
+
30
+ // `git <args>` that acts on the checkout it is pointed at (-C / cwd), never on an inherited GIT_DIR.
31
+ export const gitArgv = (...args: readonly string[]): string[] => scrubGitEnv(['git', ...args])
@@ -0,0 +1,79 @@
1
+ // Leaf without imports: heredoc bodies out of a Bash command (#369).
2
+ //
3
+ // A heredoc body is data (a file being written), not commands, so a `gh issue close` line inside
4
+ // one is no tracker write. Only the opener line stays, with every body and terminator line dropped.
5
+ // The mod imports nothing from hooks/lib, so this is a small twin of shell-scan.js's
6
+ // stripHeredocBodies; hooks/lib/heredoc-mod.test.js keeps their output equal.
7
+ // When it cannot tell, a line is kept: a lost write leaves a stale pane, an extra read costs one `gh` call.
8
+ // Known difference: an opener inside `$(..)` is not seen here (shell-scan sees it), so those lines stay.
9
+
10
+ // The heredoc openers of one line, in order: [{ tag, isDash }]. A `<<` counts outside quotes and outside
11
+ // `((..))` arithmetic, and not as a here-string (`<<<`); an opener with no word after it is no opener.
12
+ // The scan stops at an unquoted `#` that starts a comment (line start, or after whitespace or `;&|(`).
13
+ // `quoteIn` is the quote still open from an earlier line; the quote open at the end of the line is returned.
14
+ function openersOf(
15
+ line: string,
16
+ quoteIn: string,
17
+ ): { found: Array<{ tag: string; isDash: boolean }>; quote: string } {
18
+ const found: Array<{ tag: string; isDash: boolean }> = []
19
+ let quote = quoteIn
20
+ let arith = 0
21
+ for (let i = 0; i < line.length; i++) {
22
+ const c = line[i]
23
+ if (quote !== '') {
24
+ if (c === '\\' && quote === '"') i++
25
+ else if (c === quote) quote = ''
26
+ } else if (c === '\\') {
27
+ i++
28
+ } else if (line.startsWith('${', i)) {
29
+ // A parameter expansion holds no opener (`${x:-<<EOF}`); skip to its `}`, or the rest of the line.
30
+ const end = line.indexOf('}', i)
31
+ if (end < 0) break
32
+ i = end
33
+ } else if (c === '#' && (i === 0 || /[\s;&|(]/.test(line[i - 1]))) {
34
+ break
35
+ } else if (c === "'" || c === '"') {
36
+ quote = c
37
+ } else if (line.startsWith('((', i)) {
38
+ arith++
39
+ i++
40
+ } else if (arith > 0 && line.startsWith('))', i)) {
41
+ arith--
42
+ i++
43
+ } else if (arith === 0 && line.startsWith('<<', i)) {
44
+ if (line[i + 2] === '<') {
45
+ i += 2
46
+ continue
47
+ }
48
+ const isDash = line[i + 2] === '-'
49
+ const word = /^\s*((?:\\.|'[^']*'|"[^"]*"|[^\s;&|<>()'"\\])+)/.exec(line.slice(i + (isDash ? 3 : 2)))
50
+ if (word !== null) {
51
+ found.push({ tag: word[1].replace(/\\(.)|['"]/g, '$1'), isDash })
52
+ i += (isDash ? 3 : 2) + word[0].length - 1
53
+ } else {
54
+ i++
55
+ }
56
+ }
57
+ }
58
+ return { found, quote }
59
+ }
60
+
61
+ // Every line from an opener to its terminator (the terminator included) goes; a body that never ends
62
+ // takes the rest of the command, as the shell reads it.
63
+ export function stripHeredocBodies(command: string): string {
64
+ const lines = command.split('\n')
65
+ const kept: string[] = []
66
+ let quote = ''
67
+ for (let i = 0; i < lines.length; i++) {
68
+ kept.push(lines[i])
69
+ const scan = openersOf(lines[i], quote)
70
+ quote = scan.quote
71
+ for (const { tag, isDash } of scan.found) {
72
+ while (i + 1 < lines.length) {
73
+ i++
74
+ if ((isDash ? lines[i].replace(/^\t+/, '') : lines[i]) === tag) break
75
+ }
76
+ }
77
+ }
78
+ return kept.join('\n')
79
+ }
@@ -15,7 +15,8 @@ export type TicketGroups = {
15
15
  paused: readonly PaneTicket[]
16
16
  blocked: readonly PaneTicket[]
17
17
  }
18
- export type Failure = { error: unknown } | { exitCode: number; stderr: string }
18
+ // `missingRoot`: the folder `gh` was to run in, when it no longer exists (a spawn there fails like a missing binary).
19
+ export type Failure = { error: unknown; missingRoot?: string } | { exitCode: number; stderr: string }
19
20
  export type RefreshDecision = { run: true } | { run: false; waitMs: number }
20
21
 
21
22
  // The most rows one `gh issue list` is asked for.
@@ -429,6 +430,7 @@ const errorText = (err: unknown): string => {
429
430
  // One short line saying why `gh` gave nothing.
430
431
  export const failureReason = (failure: Failure): string => {
431
432
  if ('error' in failure) {
433
+ if (failure.missingRoot !== undefined) return `checkout not found: ${failure.missingRoot}`
432
434
  const text = errorText(failure.error)
433
435
  if (/ENOENT|not found/i.test(text)) return 'gh not installed'
434
436
  if (/timed? ?out/i.test(text)) return 'offline: cannot reach GitHub'
@@ -50,6 +50,8 @@ import {
50
50
  } from './map-pane-view'
51
51
  import { elsewhereInProgress, myTicketsFromRows, parseLogin, scopeLine } from './my-tickets'
52
52
  import { checkoutArg, currentRepo, isPinCheckout, isSamePin, pinKey, pinPath, refreshPin, toplevelOf } from './repo-pin'
53
+ import { gitArgv, scrubGitEnv } from './git-env'
54
+ import { stripHeredocBodies } from './heredoc'
53
55
  import { isMainTurnRunning } from './turn-state'
54
56
  import { createdIssueNumber, isWhiteboardCreate, nextFollow, whiteboardHint } from './whiteboard-follow'
55
57
  import { fit, isMissing, sameValue } from './work-state-view'
@@ -92,6 +94,24 @@ const CHART_ROWS = 3
92
94
  // Space between ticket rows on the desktop, whose native buttons would touch; the terminal stays compact.
93
95
  const DESKTOP_ROW_GAP = 0.5
94
96
  const AMBER = '#e0af68'
97
+ // Ogni sezione ha il suo glifo e colore (palette Tokyo Night del band), così si distinguono anche senza colore.
98
+ const SECTION_STYLES = {
99
+ maps: { glyph: '◈', color: '#bb9af7' },
100
+ 'in-progress': { glyph: '▶', color: ACCENT },
101
+ 'needs-you': { glyph: '!', color: '#ff9e64' },
102
+ frontier: { glyph: '◆', color: '#9ece6a' },
103
+ uat: { glyph: '◎', color: '#7dcfff' },
104
+ paused: { glyph: '‖', color: '#a9b1d6' },
105
+ blocked: { glyph: '■', color: '#f7768e' },
106
+ } as const
107
+ type SectionKey = keyof typeof SECTION_STYLES
108
+ // Il filetto a sinistra di ogni riga, nel colore della sezione: occupa GUTTER colonne.
109
+ const GUTTER = '│ '
110
+ // Sul desktop il filetto è una barra continua di questa larghezza, alta quanto tutto il blocco del ticket.
111
+ const GUTTER_BAR = 0.25
112
+ const RULE_COLOR = '#3b4261'
113
+ // Il tipo di ticket colorato; gli altri tipi restano attenuati.
114
+ const TYPE_COLORS: Readonly<Record<string, string>> = { build: '#7dcfff', decision: '#ff9e64' }
95
115
 
96
116
  const EMPTY: MapPaneData = {
97
117
  map: null,
@@ -132,7 +152,8 @@ export const myTickets = atom({ plugin: 'trailhead', key: 'myTickets' } as const
132
152
  export const pendingWork = atom({ plugin: 'trailhead', key: 'pendingWork' } as const, null as number | null)
133
153
  // Whether that press waits for the running turn to end (cancellable) or was sent to the host.
134
154
  export const pendingPhase = atom({ plugin: 'trailhead', key: 'pendingPhase' } as const, null as 'queued' | 'starting' | null)
135
- // Set once the pane was opened by the command, by itself or closed by the person: never auto-opened after.
155
+ // Set once the auto-open is done: the pane was seen drawn, or the person acted on it (the command, a close by
156
+ // hand, the close button). An unasked open that stays undrawn does not set it; never auto-opened after it is set.
136
157
  export const paneAutoOpened = atom({ plugin: 'trailhead', key: 'paneAutoOpened' } as const, false)
137
158
  const PENDING = { plugin: 'trailhead', key: 'pendingWork' } as const
138
159
  const WORK_COMMAND = 'trailhead:work'
@@ -213,12 +234,15 @@ async function setPane($: Dollar, next: MapPaneData) {
213
234
  type GhResult = { ok: true; stdout: string } | { ok: false; reason: string }
214
235
 
215
236
  // Always run in the pinned repo, so the session's cwd moving out of it never changes what is read.
237
+ // gh resolves {owner}/{repo} through git, so it gets the same scrubbed env as a git call.
216
238
  async function gh($: Dollar, pin: RepoPin, args: readonly string[]): Promise<GhResult> {
217
239
  try {
218
- const out = await $.process.run(['gh', ...args], { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
240
+ const out = await $.process.run(scrubGitEnv(['gh', ...args]), { cwd: pin.root, timeoutMs: GH_TIMEOUT_MS })
219
241
  return out.exitCode === 0 ? { ok: true, stdout: out.stdout } : { ok: false, reason: failureReason(out) }
220
242
  } catch (error) {
221
- return { ok: false, reason: failureReason({ error }) }
243
+ // A deleted cwd fails the spawn as ENOENT, the same as a missing binary: look before blaming gh.
244
+ const missingRoot = (await $.fs.exists(pin.root).catch(() => true)) ? undefined : pin.root
245
+ return { ok: false, reason: failureReason({ error, missingRoot }) }
222
246
  }
223
247
  }
224
248
 
@@ -600,6 +624,10 @@ async function restoreAfterClear($: Dollar) {
600
624
  export const GH_ISSUE_WRITE =
601
625
  /\bgh\b[^\n;&|]*\bissue\s+(?:edit|close|create|comment|reopen|delete|transfer)\b|\bgh\b(?=[^\n;&|]*\sapi\s)(?=[^\n;&|]*\brepos\/[^\s;&|]*\/issues\b)(?=[^\n;&|]*(?:\s(?:-X|--method)[\s=]*["']?(?:POST|PATCH|PUT|DELETE)\b|\s(?:-[fF]|--field\b|--raw-field\b|--input\b)))(?![^\n;&|]*\s(?:-X|--method)[\s=]*["']?GET\b)|\btrailhead-issue-body\.js\b/
602
626
 
627
+ // A tracker write once heredoc bodies are out: a body line is data, never a command (#369). The matcher
628
+ // stays the regex because the host's `command:` takes no function; the handler runs this check first.
629
+ export const isTrackerWrite = (command: string): boolean => GH_ISSUE_WRITE.test(stripHeredocBodies(command))
630
+
603
631
  // The host runs the verb in the session's own directory, which a shell `cd` may have moved, and the mod cannot
604
632
  // move it back: `out` hands the pinned checkout to the verb, which moves first (#312). A checkout that cannot
605
633
  // be read is `unknown`, refused, but not told it is elsewhere.
@@ -626,7 +654,7 @@ async function placeOf($: Dollar): Promise<{ pin: RepoPin; where: 'in' | 'out' }
626
654
  // Whether the pinned root still answers as a checkout root, the same test the verb's helper applies.
627
655
  async function isCheckoutRoot($: Dollar, root: string): Promise<boolean> {
628
656
  try {
629
- const out = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root, timeoutMs: 5_000 })
657
+ const out = await $.process.run(gitArgv('rev-parse', '--show-toplevel'), { cwd: root, timeoutMs: 5_000 })
630
658
  return out.exitCode === 0 && out.stdout.trim() === root
631
659
  } catch {
632
660
  return false
@@ -869,12 +897,17 @@ const pressDashboard = ($: Dollar, isOn: boolean): void => {
869
897
  // so the auto-open runs from a zero-delay timer, a dispatch of its own; `isOpening` keeps two
870
898
  // renders from opening twice before the flag is written.
871
899
  let isOpening = false
900
+ // Quando l'auto-open ha disegnato il pannello: serve a `/trailhead:panel` per riconoscerne uno mai visto.
901
+ let autoOpenedAt: number | null = null
872
902
  // Same for the fill an empty pane asks for from its render.
873
903
  let isFilling = false
874
904
 
875
- async function openPane($: Dollar) {
876
- await update($, paneAutoOpened, () => true)
905
+ // L'auto-open conta come fatto (`paneAutoOpened`) solo quando il pannello è davvero disegnato, o quando la
906
+ // persona è intervenuta (comando, chiusura a mano, bottone close). Un'apertura non richiesta che resta non
907
+ // disegnata non brucia il flag: un trigger successivo (render in fullscreen, `session.attach`) può riprovare.
908
+ async function openPane($: Dollar, isRequested = false) {
877
909
  const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
910
+ if (isRequested || opened.isPlaced) await update($, paneAutoOpened, () => true)
878
911
  if (opened.isPlaced) void requestRefresh($)
879
912
  return opened
880
913
  }
@@ -883,9 +916,14 @@ async function autoOpen($: Dollar) {
883
916
  if (isOpening) return
884
917
  isOpening = true
885
918
  try {
886
- if (!(await $.ui.panes()).some(pane => pane.id === PANE_ID)) await openPane($)
887
- // Found up already (a reload with the pane showing): nothing to open, and renders stop asking.
888
- else await update($, paneAutoOpened, () => true)
919
+ const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
920
+ if (pane === undefined) {
921
+ const opened = await openPane($)
922
+ if (opened.isPlaced) autoOpenedAt = await $.clock.now()
923
+ }
924
+ // Found up and drawn (a reload with the pane showing): nothing to open, and renders stop asking.
925
+ else if (pane.isPlaced) await update($, paneAutoOpened, () => true)
926
+ // Listed but undrawn: the host places it itself when it can; its own render then completes the auto-open.
889
927
  } catch (err) {
890
928
  debug($, `trailhead mod: map pane auto-open failed: ${String(err)}`)
891
929
  } finally {
@@ -893,28 +931,43 @@ async function autoOpen($: Dollar) {
893
931
  }
894
932
  }
895
933
 
896
- // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, tipico di
897
- // una sessione appena aperta) non è visibile: il comando lo riapre, e una richiesta della persona lo
898
- // disegna a qualsiasi larghezza. Se nemmeno così viene disegnato lo chiude, tranne quando non c'è ancora
899
- // nessuna superficie (`no-surface`): è l'attesa normale dell'inizio sessione, il pannello resta aperto e
900
- // viene disegnato appena una superficie si collega (il suo render chiede la prima lettura).
934
+ // Una lettura sola, da un timer a ritardo zero: un render non può scrivere stato.
935
+ async function autoOpenIfDue($: Dollar) {
936
+ if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
937
+ }
938
+
939
+ // Il render del pannello scatta solo da disegnato: l'auto-open è completato.
940
+ async function markDrawn($: Dollar) {
941
+ if (!(await read($, paneAutoOpened))) $.clock.after(0, () => void update($, paneAutoOpened, () => true).catch(err => debug($, `trailhead mod: auto-open flag not set: ${String(err)}`)))
942
+ }
943
+
944
+ // Un pannello aperto ma non disegnato (l'auto-open sotto la soglia dei pannelli non richiesti, o su una
945
+ // superficie che non ha ancora comunicato il layout) non è visibile: il comando lo riapre, e una richiesta
946
+ // della persona lo disegna a qualsiasi larghezza. Se nemmeno così viene disegnato NON lo chiude: la persona
947
+ // non l'ha mai visto, chiuderlo risponderebbe "closed" a un pannello mai mostrato. Resta aperto e viene
948
+ // disegnato appena la superficie può collocarlo. Chiude solo un pannello elencato E disegnato. Senza
949
+ // superficie collegata (`no-surface`, l'inizio sessione) la risposta è l'attesa normale.
901
950
  const PENDING_MESSAGE = 'Map panel will open as soon as the screen is ready.'
951
+ const NOT_SHOWN_MESSAGE = 'Map panel is open but not shown yet: it appears once the screen can place it.'
902
952
  const isNoSurface = (reason: string) => reason.startsWith('no-surface')
903
953
 
954
+ // Il desktop avvia la sessione al primo invio, non al +: un `/trailhead:panel` come primo messaggio arriva
955
+ // pochi millisecondi dopo l'auto-open. Nessuno fa in tempo a vedere il pannello e a digitare il comando sotto
956
+ // questa soglia, quindi la richiesta è di vederlo, non di chiuderlo.
957
+ const UNSEEN_MS = 2_000
958
+
904
959
  async function togglePanel($: Dollar): Promise<string> {
905
960
  const pane = (await $.ui.panes()).find(p => p.id === PANE_ID)
906
- if (pane?.isPlaced === false) {
907
- const reopened = await openPane($)
908
- if (reopened.isPlaced) return 'Map panel opened.'
909
- if (isNoSurface(reopened.reason)) return PENDING_MESSAGE
910
- }
911
- if (pane !== undefined) {
961
+ const isUnseen = autoOpenedAt !== null && (await $.clock.now()) - autoOpenedAt < UNSEEN_MS
962
+ autoOpenedAt = null
963
+ if (pane?.isPlaced === true && !isUnseen) {
912
964
  await $.ui.close({ id: PANE_ID })
913
965
  return 'Map panel closed.'
914
966
  }
915
- const opened = await openPane($)
967
+ const opened = await openPane($, true)
916
968
  if (opened.isPlaced) return 'Map panel opened.'
917
- return isNoSurface(opened.reason) ? PENDING_MESSAGE : `Map panel not shown: ${opened.reason}`
969
+ debug($, `trailhead mod: map pane not placed: ${opened.reason}`)
970
+ return isNoSurface(opened.reason) ? PENDING_MESSAGE : NOT_SHOWN_MESSAGE
918
971
  }
919
972
 
920
973
  // The close button: the pane goes (its `ui.close` hook stops the timers). The press counts as the
@@ -970,12 +1023,14 @@ export function registerMapPane(on: On) {
970
1023
  return result
971
1024
  })
972
1025
 
973
- // After the call ran: a Bash `gh issue` write changed the tracker.
1026
+ // After the call ran: a Bash `gh issue` write changed the tracker (heredoc bodies not counted).
974
1027
  on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
1028
+ // The matcher saw the raw text: a write named only inside a heredoc body is no write.
1029
+ if (typeof e.command !== 'string' || !isTrackerWrite(e.command)) return next(e)
975
1030
  const result = await next(e)
976
1031
  lastWriteAt = await $.clock.now()
977
1032
  void requestRefresh($, { isWrite: true })
978
- await hintWhiteboard($, e.command, result)
1033
+ await hintWhiteboard($, stripHeredocBodies(e.command), result)
979
1034
  return result
980
1035
  // This hook never judges the call: if it fails, the call goes on untouched.
981
1036
  }).catch((_$, e, next) => next(e))
@@ -986,7 +1041,14 @@ export function registerMapPane(on: On) {
986
1041
  // Only in fullscreen, where a pane docks beside the transcript; this bare matcher is
987
1042
  // narrower than the band's, and the band's own hook (in register.tsx) draws after it.
988
1043
  on('ui.render', { component: 'AbovePrompt', viewport: { isFullscreen: true } }, async ($, e, next) => {
989
- if ((await read($, activeMap)) !== null && !(await read($, paneAutoOpened))) $.clock.after(0, () => void autoOpen($))
1044
+ await autoOpenIfDue($)
1045
+ return next(e)
1046
+ })
1047
+
1048
+ // A surface that attaches later (a fresh desktop session) may place the pane the first open could not: with
1049
+ // its layout saying fullscreen, the auto-open tries again. Without a viewport the render trigger decides.
1050
+ on('session.attach', { viewport: { isFullscreen: true } }, async ($, e, next) => {
1051
+ await autoOpenIfDue($)
990
1052
  return next(e)
991
1053
  })
992
1054
 
@@ -999,6 +1061,7 @@ export function registerMapPane(on: On) {
999
1061
  })
1000
1062
 
1001
1063
  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
1064
+ await markDrawn($)
1002
1065
  const ui = $.ui.resolve(e)
1003
1066
  const { Box, Text, Button, Link } = ui
1004
1067
  // Raster is the terminal surface's only; elsewhere the constructor is not a real element.
@@ -1028,14 +1091,45 @@ export function registerMapPane(on: On) {
1028
1091
  </Box>
1029
1092
  )
1030
1093
  const link = (url: string, label: string) => (url === '' ? label : <Link href={url} label={label} />)
1094
+ // Il filetto di un blocco (riga e righe sotto): sul desktop una barra che si allunga quanto il blocco, nel
1095
+ // terminale un `│` per riga. Occupa sempre GUTTER colonne.
1096
+ const gutter = (key: string, section: SectionKey, lines: number) => {
1097
+ const { color } = SECTION_STYLES[section]
1098
+ return e.surface === 'desktop' ? (
1099
+ <Box key={key} width={GUTTER.length}>
1100
+ <Box key={`${key}-bar`} width={GUTTER_BAR} backgroundColor={color} />
1101
+ </Box>
1102
+ ) : (
1103
+ <Box key={key} flexDirection="column" width={GUTTER.length}>
1104
+ {Array.from({ length: lines }, (_, i) => (
1105
+ <Text key={`${key}-${i}`} color={color}>
1106
+ {GUTTER}
1107
+ </Text>
1108
+ ))}
1109
+ </Box>
1110
+ )
1111
+ }
1112
+ // Intestazione di sezione: glifo e nome nel colore della sezione, poi un filetto fino al bordo.
1113
+ const sectionHead = (key: SectionKey, label: string) => {
1114
+ const { glyph, color } = SECTION_STYLES[key]
1115
+ const rest = cols - glyph.length - 1 - label.length - 1
1116
+ return (
1117
+ <Box key={`section-head-${key}`}>
1118
+ {text(`glyph-${key}`, { color }, `${glyph} `)}
1119
+ {text(`head-${key}`, { bold: true, color }, label)}
1120
+ {rest > 0 ? text(`rule-${key}`, { color: RULE_COLOR }, ` ${'─'.repeat(rest)}`) : null}
1121
+ </Box>
1122
+ )
1123
+ }
1031
1124
 
1032
1125
  // The open maps, one row each; shown with several maps open (or with no active map, to pick one).
1033
1126
  // On the dashboard every map is listed and each row opens it.
1034
1127
  const mapRow = (m: MapSummary) => {
1035
- const r = mapRowLayout(m, { active, viewed, columns: cols, isDashboard: isBoard })
1128
+ const r = mapRowLayout(m, { active, viewed, columns: cols - GUTTER.length, isDashboard: isBoard })
1036
1129
  return (
1037
1130
  <Box key={`map-row-${m.number}`}>
1038
- {text(`map-mark-${m.number}`, r.isActive ? accent : {}, r.mark)}
1131
+ {gutter(`map-gutter-${m.number}`, 'maps', 1)}
1132
+ {text(`map-mark-${m.number}`, r.isActive ? { bold: true, color: SECTION_STYLES.maps.color } : {}, r.mark)}
1039
1133
  {r.isPressable ? (
1040
1134
  <Button
1041
1135
  key={`map-${m.number}`}
@@ -1052,7 +1146,7 @@ export function registerMapPane(on: On) {
1052
1146
  const isMapsShown = isBoard ? data.maps.length > 0 : data.maps.length > 1 || (active === null && data.maps.length > 0)
1053
1147
  const mapsGroup = isMapsShown ? (
1054
1148
  <Box key="group-maps" flexDirection="column" marginTop={gap}>
1055
- {text('head-maps', bold, `Maps · ${data.maps.length}`)}
1149
+ {sectionHead('maps', `Maps · ${data.maps.length}`)}
1056
1150
  {data.maps.map(mapRow)}
1057
1151
  </Box>
1058
1152
  ) : null
@@ -1091,18 +1185,20 @@ export function registerMapPane(on: On) {
1091
1185
 
1092
1186
  // This session's own ticket survives a work-state file another session took over; without one, the file decides.
1093
1187
  const isInPlay = (t: PaneTicket) => (own !== null ? t.number === own : play !== null && !orphaned && play.ticket === t.number)
1094
- const row = (t: PaneTicket, hasMarkColumn: boolean, scope: string | null = null) => {
1095
- const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols })
1188
+ const row = (t: PaneTicket, section: SectionKey, hasMarkColumn: boolean, scope: string | null = null) => {
1189
+ const r = rowLayout(t, { isInPlay: isInPlay(t), hasMarkColumn, pendingWork: pending, pendingPhase: phase, columns: cols - GUTTER.length })
1096
1190
  const style = r.isInPlay ? accent : r.isDim ? dim : {}
1097
1191
  const holder = r.isDim ? dim : {}
1192
+ const typeColor = t.type === null ? undefined : TYPE_COLORS[t.type]
1193
+ const typeStyle = r.isDim || typeColor === undefined ? dim : { color: typeColor }
1098
1194
  const line = (
1099
- <Box key={`row-${t.number}`} width={cols} justifyContent="space-between">
1195
+ <Box key={`row-${t.number}`} width={cols - GUTTER.length} justifyContent="space-between">
1100
1196
  <Box>
1101
1197
  {r.mark === null ? null : text(`mark-${t.number}`, r.isInPlay ? accent : {}, r.mark)}
1102
1198
  <Box key={`ticket-${t.number}`}>
1103
1199
  <Text {...style}>{link(t.url, r.label)}</Text>
1104
1200
  </Box>
1105
- {r.type === null ? null : text(`type-${t.number}`, dim, r.type)}
1201
+ {r.type === null ? null : text(`type-${t.number}`, typeStyle, r.type)}
1106
1202
  </Box>
1107
1203
  {r.aside?.kind === 'holder' ? text(`holder-${t.number}`, holder, r.aside.text) : null}
1108
1204
  {r.aside?.kind === 'self' ? text(`self-${t.number}`, { color: ACCENT }, r.aside.text) : null}
@@ -1130,19 +1226,22 @@ export function registerMapPane(on: On) {
1130
1226
  ) : null}
1131
1227
  </Box>
1132
1228
  )
1133
- const parent = uatParentLine(t, cols)
1134
- return parent === null && scope === null ? (
1135
- line
1136
- ) : (
1137
- <Box key={`entry-${t.number}`} flexDirection="column">
1138
- {line}
1139
- {parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
1140
- {scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
1229
+ // Le righe sotto (genitore UAT, dove sta) rientrano dopo il filetto, che copre tutto il blocco.
1230
+ const parent = uatParentLine(t, cols - GUTTER.length)
1231
+ const lines = 1 + (parent === null ? 0 : 1) + (scope === null ? 0 : 1)
1232
+ return (
1233
+ <Box key={`entry-${t.number}`} width={cols}>
1234
+ {gutter(`gutter-${t.number}`, section, lines)}
1235
+ <Box key={`entry-lines-${t.number}`} flexDirection="column">
1236
+ {line}
1237
+ {parent === null ? null : text(`uat-of-${t.number}`, dim, parent)}
1238
+ {scope === null ? null : text(`scope-of-${t.number}`, dim, scope)}
1239
+ </Box>
1141
1240
  </Box>
1142
1241
  )
1143
1242
  }
1144
1243
  // `elsewhere`: my tickets of other scopes, listed after the shown scope's with the line naming where each is.
1145
- const group = (key: string, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
1244
+ const group = (key: Exclude<SectionKey, 'maps'>, name: string, rows: readonly PaneTicket[], elsewhere: readonly MyTicket[] = []) => {
1146
1245
  if (rows.length + elsewhere.length === 0) return null
1147
1246
  const hasMarkColumn = [...rows, ...elsewhere].some(isInPlay)
1148
1247
  return (
@@ -1152,9 +1251,9 @@ export function registerMapPane(on: On) {
1152
1251
  marginTop={gap}
1153
1252
  {...(e.surface === 'desktop' ? { rowGap: DESKTOP_ROW_GAP } : {})}
1154
1253
  >
1155
- {text(`head-${key}`, bold, `${name} · ${rows.length + elsewhere.length}`)}
1156
- {rows.map(t => row(t, hasMarkColumn))}
1157
- {elsewhere.map(t => row(t, hasMarkColumn, scopeLine(t, data.maps, cols)))}
1254
+ {sectionHead(key, `${name} · ${rows.length + elsewhere.length}`)}
1255
+ {rows.map(t => row(t, key, hasMarkColumn))}
1256
+ {elsewhere.map(t => row(t, key, hasMarkColumn, scopeLine(t, data.maps, cols - GUTTER.length)))}
1158
1257
  </Box>
1159
1258
  )
1160
1259
  }
@@ -1173,7 +1272,7 @@ export function registerMapPane(on: On) {
1173
1272
  const notes = (d: { unverifiedCount: number; isCapped: boolean }) => [
1174
1273
  d.unverifiedCount === 0 ? null : (
1175
1274
  <Box key="unverified" marginTop={gap}>
1176
- <Text {...dim}>{unverifiedLine(d.unverifiedCount)}</Text>
1275
+ <Text color={AMBER}>{unverifiedLine(d.unverifiedCount)}</Text>
1177
1276
  </Box>
1178
1277
  ),
1179
1278
  !d.isCapped ? null : (