@marcomigozzi/trailhead 0.15.0 → 0.16.1

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 +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  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 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -0,0 +1,65 @@
1
+ // Liveness di una sessione Claude Code dal registro del motore (<config>/sessions/<pid>.json,
2
+ // con `sessionId` e `pid`). Nessuna chiamata al motore qui: fs e controllo del pid sono iniettati.
3
+ // Il registro e' interno e non tipizzato, quindi ogni dubbio (cartella illeggibile, file non
4
+ // parsabile, registro vuoto) vale "unknown", mai "gone".
5
+
6
+ export type Liveness = 'live' | 'gone' | 'unknown'
7
+
8
+ export type RegistryDeps = {
9
+ // I nomi dei file della cartella del registro; rifiuta se non si legge.
10
+ list: (dir: string) => Promise<string[]>
11
+ read: (path: string) => Promise<string>
12
+ // true/false se il pid si sa vivo/morto, null se la piattaforma non lo dice (conta la sola presenza).
13
+ pidAlive: (pid: number) => Promise<boolean | null>
14
+ }
15
+
16
+ type Entry = { sessionId: string; pid: number }
17
+
18
+ const parseEntry = (text: string): Entry | null => {
19
+ let raw: unknown
20
+ try {
21
+ raw = JSON.parse(text)
22
+ } catch {
23
+ return null
24
+ }
25
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
26
+ const r = raw as Record<string, unknown>
27
+ if (typeof r.sessionId !== 'string' || r.sessionId === '') return null
28
+ if (!Number.isInteger(r.pid) || (r.pid as number) <= 0) return null
29
+ return { sessionId: r.sessionId, pid: r.pid as number }
30
+ }
31
+
32
+ export async function sessionLiveness(dir: string, sessionId: string, deps: RegistryDeps): Promise<Liveness> {
33
+ let names: string[]
34
+ try {
35
+ names = (await deps.list(dir)).filter(name => name.endsWith('.json'))
36
+ } catch {
37
+ return 'unknown'
38
+ }
39
+ let parsed = 0
40
+ let doubtful = false
41
+ for (const name of names) {
42
+ let entry: Entry | null = null
43
+ try {
44
+ entry = parseEntry(await deps.read(`${dir}/${name}`))
45
+ } catch {
46
+ entry = null
47
+ }
48
+ if (entry === null) {
49
+ doubtful = true
50
+ continue
51
+ }
52
+ parsed += 1
53
+ if (entry.sessionId !== sessionId) continue
54
+ let alive: boolean | null
55
+ try {
56
+ alive = await deps.pidAlive(entry.pid)
57
+ } catch {
58
+ alive = null
59
+ }
60
+ if (alive !== false) return 'live'
61
+ }
62
+ // Nessuna voce leggibile (la sessione corrente dovrebbe esserci) o file che non si leggono: non si sa.
63
+ if (parsed === 0 || doubtful) return 'unknown'
64
+ return 'gone'
65
+ }
@@ -0,0 +1,14 @@
1
+ // Whether a main-loop turn is running, shared by the band (register.tsx) and the map pane, which each
2
+ // need it and may not hook the same event twice with the same matcher. Module state: a hot reload
3
+ // resets it to idle, which only costs a held press being released early.
4
+ let isRunning = false
5
+
6
+ export const markTurnStart = (): void => {
7
+ isRunning = true
8
+ }
9
+
10
+ export const markTurnComplete = (): void => {
11
+ isRunning = false
12
+ }
13
+
14
+ export const isMainTurnRunning = (): boolean => isRunning
@@ -0,0 +1,98 @@
1
+ // Pure rules of the map panel following the whiteboard (#306): when this session takes a whiteboard
2
+ // ticket the panel shows the whiteboard once, and a whiteboard ticket created from a map raises a hint.
3
+ // No atoms and no engine calls here, so the rules are easy to test.
4
+ import type { WorkState } from '../../types'
5
+
6
+ export const WHITEBOARD_LABEL = 'trailhead:whiteboard'
7
+
8
+ export type Follow = { follow: boolean; memo: number | null }
9
+
10
+ // Follow once per ticket: the memo is the last ticket followed, kept by the caller across a /clear.
11
+ // A null state (no file, cleared by Resolve) changes nothing; a map ticket, a foreign or an orphaned state
12
+ // is never followed and leaves the memo as it was.
13
+ export const nextFollow = (memo: number | null, state: WorkState | null, own: number | null, isOrphaned: boolean): Follow => {
14
+ if (state === null || state.map !== null || isOrphaned || own === null || state.ticket !== own) return { follow: false, memo }
15
+ return state.ticket === memo ? { follow: false, memo } : { follow: true, memo: state.ticket }
16
+ }
17
+
18
+ // The command split into shell segments of words: quotes group (and are dropped), a backslash escapes outside
19
+ // single quotes, a backslash-newline joins lines, and an unquoted `;`, `&`, `|` or newline ends a segment.
20
+ // Enough shell for reading flags, not a full parser: no expansion, no heredocs.
21
+ const SEPARATORS = new Set([';', '&', '|', '\n'])
22
+
23
+ function segmentsOf(command: string): string[][] {
24
+ const segments: string[][] = []
25
+ let words: string[] = []
26
+ let word: string | null = null
27
+ let quote: '"' | "'" | null = null
28
+ const endWord = () => {
29
+ if (word !== null) words = [...words, word]
30
+ word = null
31
+ }
32
+ for (let i = 0; i < command.length; i++) {
33
+ const ch = command[i]
34
+ if (quote !== null) {
35
+ if (ch === quote) quote = null
36
+ else if (ch === '\\' && quote === '"' && i + 1 < command.length) word = (word ?? '') + command[++i]
37
+ else word = (word ?? '') + ch
38
+ } else if (ch === '\\' && command[i + 1] === '\n') i++
39
+ else if (ch === '\\' && i + 1 < command.length) word = (word ?? '') + command[++i]
40
+ else if (ch === '"' || ch === "'") {
41
+ quote = ch
42
+ word = word ?? ''
43
+ } else if (SEPARATORS.has(ch)) {
44
+ endWord()
45
+ if (words.length > 0) segments.push(words)
46
+ words = []
47
+ } else if (/\s/.test(ch)) endWord()
48
+ else word = (word ?? '') + ch
49
+ }
50
+ endWord()
51
+ if (words.length > 0) segments.push(words)
52
+ return segments
53
+ }
54
+
55
+ const isCreate = (words: string[]): boolean => {
56
+ const at = words.indexOf('issue')
57
+ return words.includes('gh') && at >= 0 && words[at + 1] === 'create'
58
+ }
59
+
60
+ // The values of every label flag: `--label X`, `--label=X`, `-l X`, `-lX`.
61
+ function labelValues(words: string[]): string[] {
62
+ const values: string[] = []
63
+ words.forEach((w, i) => {
64
+ if ((w === '--label' || w === '-l') && i + 1 < words.length) values.push(words[i + 1])
65
+ else if (w.startsWith('--label=')) values.push(w.slice('--label='.length))
66
+ else if (w.startsWith('-l') && !w.startsWith('--') && w.length > 2) values.push(w.slice(2))
67
+ })
68
+ return values
69
+ }
70
+
71
+ // A `gh issue create` whose label flags (repeated, `=` or space, comma lists, quoted) carry the whiteboard
72
+ // label. Only the create's own segment counts, so a label named in another command never does.
73
+ export const isWhiteboardCreate = (command: string): boolean =>
74
+ segmentsOf(command)
75
+ .filter(isCreate)
76
+ .some(words => labelValues(words).some(v => v.split(',').some(label => label.trim() === WHITEBOARD_LABEL)))
77
+
78
+ const ISSUE_URL = /\/issues\/(\d+)\s*$/
79
+
80
+ const textOf = (value: unknown): string | null => {
81
+ if (typeof value === 'string') return value
82
+ const stdout = (value as { stdout?: unknown } | null)?.stdout
83
+ return typeof stdout === 'string' ? stdout : null
84
+ }
85
+
86
+ // The number `gh issue create` printed (the issue URL is its last line), from what `next(e)` resolved to:
87
+ // the Bash `result` is `{ stdout }` or a plain string; the call's `text` is the fallback. An errored call,
88
+ // or output with no issue URL, gives none.
89
+ export const createdIssueNumber = (call: unknown): number | null => {
90
+ const c = call as { result?: unknown; text?: unknown; isError?: unknown } | null
91
+ if (c === null || typeof c !== 'object' || c.isError === true) return null
92
+ const out = textOf(c.result) ?? textOf(c.text)
93
+ const m = out === null ? null : ISSUE_URL.exec(out.trimEnd())
94
+ return m === null ? null : Number(m[1])
95
+ }
96
+
97
+ // The toast box is narrow: a few words.
98
+ export const whiteboardHint = (n: number): string => `new whiteboard ticket #${n}: open Whiteboard`
@@ -0,0 +1,191 @@
1
+ // Where the session's work state lives when it is not in the pinned checkout (#270): the engine writes
2
+ // it in the per-ticket workspace (a git worktree or a sibling clone `<repo>-t<n>`), while the session
3
+ // may sit in the main checkout. Pure parts first, then the engine part that looks the state up.
4
+ import type { EngineInterface } from 'claude-code'
5
+
6
+ import { isMissing, parseWorkState } from './work-state-view'
7
+ import { MAX_FAILURES, parseWorktreeBranches } from './worktree-porcelain'
8
+
9
+ const STATE_PATH = '.trailhead/work-state.json'
10
+ const LOOKUP_TIMEOUT_MS = 5_000
11
+
12
+ export const dirname = (path: string): string => path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
13
+ const basename = (path: string): string => path.replace(/\/+$/, '').replace(/^.*\//, '')
14
+ export const trim = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
15
+
16
+ // The checkout paths of `git worktree list --porcelain`, in the order git prints them (main first).
17
+ export const parseWorktrees = (porcelain: string): string[] => parseWorktreeBranches(porcelain).map(w => w.path)
18
+
19
+ // The per-ticket clones beside the main working tree: `<basename>-t<digits>`, sorted.
20
+ export const siblingClones = (mainRoot: string, names: readonly string[]): string[] => {
21
+ const base = basename(mainRoot).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
22
+ const parent = dirname(mainRoot)
23
+ const pattern = new RegExp(`^${base}-t\\d+$`)
24
+ return names
25
+ .filter(name => pattern.test(name))
26
+ .sort()
27
+ .map(name => `${parent === '/' ? '' : parent}/${name}`)
28
+ }
29
+
30
+ // Where to look, in order: the worktrees, then the sibling clones.
31
+ // The pinned root is the own read, which already missed; no path twice.
32
+ export const candidates = (pinned: string, worktrees: readonly string[], siblings: readonly string[]): string[] => {
33
+ const all = [...worktrees, ...siblings].map(trim)
34
+ return all.filter((path, i) => path !== trim(pinned) && all.indexOf(path) === i)
35
+ }
36
+
37
+ // A state is this session's only when it names the session: a state with no session, or another's, is not adopted.
38
+ export const isOwnState = (text: string, session: string): boolean => parseWorkState(text)?.session === session
39
+
40
+ // A state that names another session (a state with no session is nobody's, shown as is).
41
+ export const isForeignState = (text: string, session: string): boolean => {
42
+ const owner = parseWorkState(text)?.session
43
+ return owner !== undefined && owner !== null && owner !== session
44
+ }
45
+
46
+ // The first candidate's text that is this session's state.
47
+ export const pickOwn = (texts: readonly (string | null)[], session: string): number => texts.findIndex(text => text !== null && isOwnState(text, session))
48
+
49
+ // The checkout where this session's state was last found, with the repo it belongs to; machinery, like
50
+ // `remembered` in repo-pin.ts. A checkout of another repo is never tried.
51
+ let lastFound: { key: string; root: string } | null = null
52
+
53
+ // A hit ends the run of failed lookups for the repo.
54
+ export const noteFound = (found: { key: string; root: string } | null): void => {
55
+ lastFound = found
56
+ if (found !== null) for (const memo of failures.keys()) if (memo.startsWith(`${found.key}|`)) failures.delete(memo)
57
+ }
58
+ // Whether the state was last found in this repo, in a checkout other than the pinned one.
59
+ export const wasFoundElsewhere = (key: string, pinned: string): boolean =>
60
+ lastFound !== null && lastFound.key === key && trim(lastFound.root) !== trim(pinned)
61
+
62
+ // A scan that found nothing is not repeated for NO_STATE_MS, per repo and session. Time comes from the
63
+ // engine clock; without one nothing is kept.
64
+ const NO_STATE_MS = 10_000
65
+ const misses = new Map<string, number>()
66
+ // A scan that failed is remembered for a shorter while, with how many failed in a row: past MAX_FAILURES
67
+ // the caller stops keeping the last state and treats the file as missing.
68
+ const FAILED_MS = 3_000
69
+ const failures = new Map<string, { at: number | null; count: number }>()
70
+
71
+ // Writes a miss time, dropping the misses whose window has ended.
72
+ const remember = (map: Map<string, number>, key: string, now: number, windowMs: number): void => {
73
+ for (const [k, at] of map) if (now - at >= windowMs) map.delete(k)
74
+ map.set(key, now)
75
+ }
76
+
77
+ export const resetLocate = (): void => {
78
+ lastFound = null
79
+ misses.clear()
80
+ failures.clear()
81
+ }
82
+
83
+ async function nowOf($: EngineInterface): Promise<number | null> {
84
+ try {
85
+ return await $.clock.now()
86
+ } catch {
87
+ return null
88
+ }
89
+ }
90
+
91
+ // null: the listing itself failed (not the same as an empty one). Path and branch of each checkout.
92
+ export async function listWorktreeBranches($: EngineInterface, root: string): Promise<{ path: string; branch: string | null }[] | null> {
93
+ try {
94
+ const out = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root, timeoutMs: LOOKUP_TIMEOUT_MS })
95
+ return out.exitCode === 0 ? parseWorktreeBranches(out.stdout) : null
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ async function listWorktrees($: EngineInterface, root: string): Promise<string[] | null> {
102
+ return (await listWorktreeBranches($, root))?.map(w => w.path) ?? null
103
+ }
104
+
105
+ export async function listSiblings($: EngineInterface, mainRoot: string): Promise<string[] | null> {
106
+ try {
107
+ const entries = await $.fs.list(dirname(mainRoot))
108
+ return siblingClones(mainRoot, entries.filter(entry => entry.kind === 'dir').map(entry => entry.name))
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ // The file's text; null when it is not there, undefined when the read failed any other way.
115
+ export async function readAt($: EngineInterface, root: string): Promise<string | null | undefined> {
116
+ try {
117
+ return (await $.fs.read(`${root}/${STATE_PATH}`)) as string
118
+ } catch (err) {
119
+ return isMissing(err) ? null : undefined
120
+ }
121
+ }
122
+
123
+ export type Lookup = { kind: 'found'; text: string; root: string } | { kind: 'none' } | { kind: 'failed' }
124
+
125
+ const NONE: Lookup = { kind: 'none' }
126
+
127
+ async function scan($: EngineInterface, pinned: string, session: string): Promise<{ kind: 'found'; text: string; root: string } | { kind: 'miss'; hadFailure: boolean }> {
128
+ const worktrees = await listWorktrees($, pinned)
129
+ const siblings = await listSiblings($, worktrees?.[0] ?? pinned)
130
+ const roots = candidates(pinned, worktrees ?? [], siblings ?? [])
131
+ const texts = await Promise.all(roots.map(root => readAt($, root)))
132
+ const i = pickOwn(texts.map(text => text ?? null), session)
133
+ if (i !== -1) return { kind: 'found', text: texts[i] as string, root: roots[i] }
134
+ return { kind: 'miss', hadFailure: worktrees === null || siblings === null || texts.includes(undefined) }
135
+ }
136
+
137
+ // This session's state from another checkout of the repo. 'none' is a clean miss; 'failed' means the
138
+ // lookup itself broke (git, the listing, a read), so the caller keeps what it had. `fresh` skips the
139
+ // no-state memory (a helper call just wrote a state).
140
+ export async function locateOwnState($: EngineInterface, pin: { key: string; root: string }, session: string, fresh = false): Promise<Lookup> {
141
+ const memo = `${pin.key}|${session}`
142
+ try {
143
+ // The remembered checkout of this repo is read first and alone, so a steady state costs no process.
144
+ const first = lastFound !== null && lastFound.key === pin.key && trim(lastFound.root) !== trim(pin.root) ? lastFound.root : null
145
+ const fast = first === null ? null : await readAt($, first)
146
+ if (first !== null && typeof fast === 'string' && isOwnState(fast, session)) {
147
+ failures.delete(memo)
148
+ return { kind: 'found', text: fast, root: first }
149
+ }
150
+ const now = await nowOf($)
151
+ const seen = misses.get(memo)
152
+ const failed = failures.get(memo)
153
+ if (!fresh && now !== null && seen !== undefined && now - seen < NO_STATE_MS) return NONE
154
+ if (!fresh && now !== null && failed?.at != null && now - failed.at < FAILED_MS) return failedLookup(failed.count)
155
+ const result = await scan($, pin.root, session)
156
+ if (result.kind === 'miss' && result.hadFailure) {
157
+ const count = (failed?.count ?? 0) + 1
158
+ setFailure(memo, count, now)
159
+ return failedLookup(count)
160
+ }
161
+ failures.delete(memo)
162
+ if (result.kind === 'found') {
163
+ misses.delete(memo)
164
+ noteFound({ key: pin.key, root: result.root })
165
+ return result
166
+ }
167
+ if (now !== null) remember(misses, memo, now, NO_STATE_MS)
168
+ if (lastFound?.key === pin.key) noteFound(null)
169
+ return NONE
170
+ } catch {
171
+ return failedLookup(bump(memo, await nowOf($)))
172
+ }
173
+ }
174
+
175
+ // Failed lookups in a row count against the keep: the MAX_FAILURES-th reads as a missing file.
176
+ const failedLookup = (count: number): Lookup => (count >= MAX_FAILURES ? NONE : { kind: 'failed' })
177
+
178
+ // The run of failures for the key; only a success ends it, however far apart the failures are. Writing
179
+ // one drops the runs of other keys that are long gone.
180
+ const FAILURE_KEEP_MS = 600_000
181
+
182
+ function setFailure(memo: string, count: number, now: number | null): void {
183
+ for (const [k, v] of failures) if (k !== memo && (v.at === null || (now !== null && now - v.at >= FAILURE_KEEP_MS))) failures.delete(k)
184
+ failures.set(memo, { at: now, count })
185
+ }
186
+
187
+ function bump(memo: string, now: number | null): number {
188
+ const count = (failures.get(memo)?.count ?? 0) + 1
189
+ setFailure(memo, count, now)
190
+ return count
191
+ }
@@ -8,12 +8,8 @@ const ELLIPSIS = '…'
8
8
  const encoder = new TextEncoder()
9
9
  const bytes = (text: string): number => encoder.encode(text).length
10
10
 
11
- // owner/name from a GitHub origin URL (https, scp form, ssh); null for anything else.
12
- export const repoSlug = (remote: string | null): string | null => {
13
- if (remote === null) return null
14
- const m = /^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remote.trim())
15
- return m === null ? null : `${m[1]}/${m[2]}`
16
- }
11
+ // The repo the links point at: host and owner/name of the pin.
12
+ export type LinkRepo = { host: string; slug: string }
17
13
 
18
14
  // Flat single line, cut by code point so a surrogate pair is never split.
19
15
  // Zero-width and bidi controls are dropped, so the title cannot hide or reorder text.
@@ -40,7 +36,7 @@ const capBytes = (text: string): string => {
40
36
  return out
41
37
  }
42
38
 
43
- const link = (slug: string | null, n: number): string => (slug === null ? '' : ` https://github.com/${slug}/issues/${n}`)
39
+ const link = (repo: LinkRepo | null, n: number): string => (repo === null ? '' : ` https://${repo.host}/${repo.slug}/issues/${n}`)
44
40
 
45
41
  const stepLine = (step: string | null): string => {
46
42
  const live = 'may have moved on since this snapshot; .trailhead/work-state.json holds the live one'
@@ -48,13 +44,13 @@ const stepLine = (step: string | null): string => {
48
44
  return `Step: ${step ?? 'unknown'} (${live})`
49
45
  }
50
46
 
51
- export const promptSection = (state: WorkState | null, orphaned: boolean, slug: string | null): string | null => {
47
+ export const promptSection = (state: WorkState | null, orphaned: boolean, repo: LinkRepo | null): string | null => {
52
48
  if (state === null || state.ticket === null || orphaned) return null
53
49
  const title = state.title === null ? '' : ` "${cutTitle(state.title)}"`
54
50
  const lines = [
55
51
  '# trailhead work state',
56
- state.map === null ? 'Map: none (whiteboard ticket)' : `Map: #${state.map}${link(slug, state.map)}`,
57
- `Ticket: #${state.ticket}${title}${link(slug, state.ticket)}`,
52
+ state.map === null ? 'Map: none (whiteboard ticket)' : `Map: #${state.map}${link(repo, state.map)}`,
53
+ `Ticket: #${state.ticket}${title}${link(repo, state.ticket)}`,
58
54
  stepLine(state.step),
59
55
  ]
60
56
  if (state.auto) lines.push('Mode: autonomous run')
@@ -62,8 +58,10 @@ export const promptSection = (state: WorkState | null, orphaned: boolean, slug:
62
58
  return capBytes(lines.join('\n'))
63
59
  }
64
60
 
65
- // What a rebuild keys on: step and autoDone are left out so the text stays cache-stable.
66
- export const snapshotKey = (state: WorkState | null, orphaned: boolean): string =>
67
- JSON.stringify(
68
- state === null ? [null, orphaned] : [state.map, state.ticket, state.title, orphaned, state.auto],
69
- )
61
+ // What a rebuild keys on: step and autoDone are left out so the text stays cache-stable. The repo is in it,
62
+ // so a pin that moves rebuilds the links.
63
+ export const snapshotKey = (state: WorkState | null, orphaned: boolean, repo: LinkRepo | null): string =>
64
+ JSON.stringify([
65
+ repo === null ? null : `${repo.host}/${repo.slug}`,
66
+ ...(state === null ? [null, orphaned] : [state.map, state.ticket, state.title, orphaned, state.auto]),
67
+ ])
@@ -1,6 +1,7 @@
1
1
  // Pure view logic of the trailhead mod: parse the work state file, build the
2
2
  // status line and the pipeline stages. No engine calls here, so it is easy to test.
3
- import type { WorkState } from '../../types'
3
+ import type { AutoRun, RunCounts, WorkState } from '../../types'
4
+ import { RESUME_HINT_STATUS, runLabel, runParts } from './auto-run-view'
4
5
 
5
6
  export const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'] as const
6
7
  export const STAGES = ['discuss', 'plan', 'execute', 'verify'] as const
@@ -11,6 +12,7 @@ export type Stage = { name: StageName; status: StageStatus }
11
12
  export type Pipeline = { stages: readonly Stage[]; isPaused: boolean }
12
13
 
13
14
  const ELLIPSIS = '…'
15
+ export const ELSEWHERE = 'in another session'
14
16
 
15
17
  // Cuts by code point, so a surrogate pair (an emoji in an issue title) is never split.
16
18
  export const fit = (text: string, room: number): string => {
@@ -85,15 +87,29 @@ export const nextOwnTicket = (
85
87
 
86
88
  export const sameValue = (a: unknown, b: unknown): boolean => stableJson(a) === stableJson(b)
87
89
 
88
- export const statusLine = (state: WorkState | null, isOrphaned: boolean): string | undefined => {
89
- if (state === null || state.ticket === null) return undefined
90
+ // What the autonomous run adds to the status: the run found beside the main checkout, its scope counts, and
91
+ // whether the resume hint shows. `elsewhere`: the foreign state's session is not known gone, so the
92
+ // status words it neutrally instead of offering to resume it.
93
+ export type RunInfo = { run: AutoRun | null; counts: RunCounts | null; hint: boolean; elsewhere?: boolean }
94
+
95
+ export const statusLine = (state: WorkState | null, isOrphaned: boolean, info?: RunInfo): string | undefined => {
96
+ const hint = info?.hint === true ? RESUME_HINT_STATUS : null
97
+ if (state === null || state.ticket === null) {
98
+ // Nessuno stato proprio: il run (slot senza sessione) o il suggerimento di ripresa, se ce ne sono.
99
+ if (hint !== null) return hint
100
+ return info?.run == null ? undefined : runLabel(info.run, info.counts)
101
+ }
90
102
  const where = state.map === null ? 'whiteboard' : `map #${state.map}`
91
103
  const ticket = state.title === null ? `#${state.ticket}` : `#${state.ticket} ${state.title}`
92
104
  // No own "trailhead" prefix: the engine already prefixes a plugin's status with its name.
93
105
  const parts = [where, ticket]
94
106
  if (state.step !== null) parts.push(state.step)
95
- if (state.auto) parts.push(`auto ${state.autoDone} done`)
96
- if (isOrphaned) parts.push('orphaned, resume?')
107
+ if (state.auto) {
108
+ const [first, ...rest] = runParts(info?.run ?? { done: state.autoDone }, info?.counts ?? null)
109
+ parts.push(`auto ${first}`, ...rest)
110
+ }
111
+ if (isOrphaned) parts.push(info?.elsewhere === true ? ELSEWHERE : 'orphaned, resume?')
112
+ if (hint !== null) parts.push(hint)
97
113
  return parts.join(' · ')
98
114
  }
99
115
 
@@ -0,0 +1,17 @@
1
+ // Foglia senza import, condivisa da work-state-locate e dal run autonomo: il parser di
2
+ // `git worktree list --porcelain` e il tetto dei fallimenti di fila.
3
+
4
+ // Quanti fallimenti di fila tengono l'ultimo risultato noto prima di azzerarlo.
5
+ export const MAX_FAILURES = 3
6
+
7
+ // Path and full branch ref of each checkout in `git worktree list --porcelain` (null: detached or bare).
8
+ export const parseWorktreeBranches = (porcelain: string): { path: string; branch: string | null }[] =>
9
+ porcelain
10
+ .split(/\n\s*\n/)
11
+ .map(block => block.split('\n'))
12
+ .filter(lines => lines.some(line => line.startsWith('worktree /')))
13
+ .map(lines => {
14
+ const path = (lines.find(line => line.startsWith('worktree ')) as string).slice('worktree '.length).replace(/(.)\/+$/, '$1')
15
+ const branch = lines.find(line => line.startsWith('branch '))
16
+ return { path, branch: branch === undefined ? null : branch.slice('branch '.length) }
17
+ })
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // trailhead-bash-guards.js: PreToolUse(Bash) hook.
3
- // One process for the five Bash guards (commit, secret, install, search, body)
4
- // instead of five: each Bash call pays a single node start-up. The guards keep
3
+ // One process for the six Bash guards (commit, secret, install, search, body, labels)
4
+ // instead of six: each Bash call pays a single node start-up. The guards keep
5
5
  // their own modules and verdicts; this runner only calls each one's pure
6
- // evaluate(cmd) in a fixed order (commit, secret, install, search, body).
6
+ // evaluate(cmd) in a fixed order (commit, secret, install, search, body, labels).
7
7
  // - First block wins: its payload goes to stdout and the hook exits 2; later
8
8
  // guards do not run. An earlier advisory keeps its stderr line.
9
9
  // - No block: advisories (secret-guard's "could not scan") surface on stderr
@@ -11,9 +11,9 @@
11
11
  // - Failure posture is unchanged per guard: a throwing guard counts as allow
12
12
  // and the others still run; a guard module that fails to load is skipped
13
13
  // with a one-line stderr warning.
14
- // - --guards=<name,...> picks the guards (default: all five). The Codex install
15
- // ships no search-guard and registers --guards=commit,secret,install,body.
16
- // A missing, empty, malformed or unknown-name list falls back to all five, so
14
+ // - --guards=<name,...> picks the guards (default: all six). The Codex install
15
+ // ships no search-guard and registers --guards=commit,secret,install,body,labels.
16
+ // A missing, empty, malformed or unknown-name list falls back to all six, so
17
17
  // a config typo never disables a guard.
18
18
  // - TRAILHEAD_MOD_GUARDS=<name,...>@<epoch-ms>: the mod (which has ported those
19
19
  // guards) stamps the guards it already ran; a stamp no older than
@@ -22,7 +22,7 @@
22
22
  // Crash-safe: unparseable input or any internal error -> exit 0.
23
23
  // Self-contained, no dependencies. Pure functions are exported for tests.
24
24
 
25
- const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body'];
25
+ const GUARD_ORDER = ['commit', 'secret', 'install', 'search', 'body', 'labels'];
26
26
  const MOD_GUARDS_FRESH_MS = 5000;
27
27
  const MOD_GUARDS_RE = /^([a-z][a-z0-9-]*(?:,[a-z][a-z0-9-]*)*)@(\d+)$/;
28
28
 
@@ -37,7 +37,7 @@ function parseModGuards(value, now = Date.now()) {
37
37
  }
38
38
 
39
39
  // Guards selected by --guards=<name,...>, in canonical order. Anything but a
40
- // well-formed list of known names means all five.
40
+ // well-formed list of known names means all six.
41
41
  function parseGuardsArg(argv) {
42
42
  const arg = argv.find((a) => a.startsWith('--guards='));
43
43
  if (!arg) return GUARD_ORDER;