@marcomigozzi/trailhead 0.16.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 (39) hide show
  1. package/README.it.md +10 -8
  2. package/README.md +10 -8
  3. package/bin/config-dir-templates-docs.test.js +52 -0
  4. package/bin/decision-builds-docs.test.js +75 -0
  5. package/bin/fog-vs-ticket-docs.test.js +56 -0
  6. package/bin/lib/codex-projection.js +8 -1
  7. package/bin/lib/codex-projection.test.js +23 -0
  8. package/bin/lib/host-descriptor.js +14 -1
  9. package/bin/lib/host-descriptor.test.js +18 -0
  10. package/bin/lib/runtime-command-literals.test.js +184 -0
  11. package/bin/report-status-docs.test.js +1 -1
  12. package/package.json +1 -1
  13. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  14. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  15. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  16. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  17. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  18. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  19. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  20. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  21. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  22. package/plugins/trailhead/hooks/mod/README.md +8 -3
  23. package/plugins/trailhead/hooks/mod/map-pane.tsx +52 -28
  24. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  25. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  26. package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -2
  27. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  28. package/plugins/trailhead/skills/_shared/teamwork.md +3 -3
  29. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  31. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  32. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  34. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  35. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -3
  36. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -0
  37. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  38. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  39. package/plugins/trailhead/tests/map-pane-ui.test.ts +179 -2
@@ -252,12 +252,17 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
252
252
  timer started on open. A write is `gh issue edit|close|create|comment|reopen|delete|transfer`, a `gh api` call on a
253
253
  `repos/.../issues` path with a write method (`-X`/`--method` POST, PATCH, PUT, DELETE) or an implicit POST
254
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
255
+ (`GH_ISSUE_WRITE`); reads never refresh. Never two fetches inside `MIN_REFRESH_MS` (save the turn-end read below): a request inside the floor
256
256
  keeps one trailing refresh for the rest of it. A write that reads at once also arms that trailing refresh, so one
257
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
258
+ 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
259
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
260
+ and the next trigger starts it again (`isTimerLate`). The trailing refresh is kept with its due time the same way:
261
+ a dropped one is replaced by the next trigger, and a render past its due time by more than `TIMER_GRACE_MS` asks
262
+ for the read at once. A turn end that follows a tracker write newer than the last read start reads at once,
263
+ floor or not (one extra read per turn at most, never without a write), so the final state shows with no timer
264
+ involved; it leaves an armed trailing refresh alone, so the read after the search index caught up still follows.
265
+ A read asked while the pane is undrawn is dropped; a render
261
266
  whose list (with no error) is older than `TIMER_MS + TIMER_GRACE_MS` asks for a read at once, as an empty view does.
262
267
  - **Auto-open**: once, in fullscreen (`e.viewport.isFullscreen === true` in the `AbovePrompt` render),
263
268
  when a map is active. Never on the main screen, never again after the pane was opened or closed
@@ -159,8 +159,12 @@ function debug($: Dollar, message: string): void {
159
159
  // A hot reload resets it, which costs at most one early refresh.
160
160
  let queue: Promise<void> = Promise.resolve()
161
161
  let generation = 0
162
+ // Bumped by every pin move, same repo or not: a read that spans one is stale even when `generation` held.
163
+ let pinMoves = 0
162
164
  let lastStartedAt: number | null = null
163
- let trailing: Timer | null = null
165
+ // When the last tracker write finished: a turn end that finds it newer than the last read start reads at once.
166
+ let lastWriteAt: number | null = null
167
+ let trailing: Tracked | null = null
164
168
  let ticker: Tracked | null = null
165
169
  // What /clear would take from the pane, kept across it (module variables survive it, atoms do not).
166
170
  type Carried = { isAutoOpened: boolean; viewed: number | null; active: number | null; isDashboard: boolean }
@@ -307,18 +311,18 @@ async function dropClosedView($: Dollar, maps: readonly MapSummary[] | null) {
307
311
  }
308
312
 
309
313
  // One read of the viewed (else active) map. The result is dropped when the pane closed meanwhile
310
- // (generation) or the target moved on, checked before every write; a moved target is read again.
311
- async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
314
+ // (generation) or the target or pin moved on, checked before every write; a moved one is read again.
315
+ async function fetchMap($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
312
316
  const target = await readTarget($)
313
317
  if (token !== generation) return
314
- // Checked before every write: the last test is the token, with no await after it. A pin that moved to
315
- // another repo (even with the same map number there) makes the read stale; the target is not read
316
- // under the new pin for it.
318
+ // Checked before every write: the last test is the token, then the pin-move count, with no await after
319
+ // them. A pin that moved to another repo (even with the same map number there) makes the read stale; the
320
+ // target is not read under the new pin for it. A move that landed during the checks shows in the count.
317
321
  const isStale = async () => {
318
322
  if (token !== generation) return true
319
323
  const isMoved = !isSamePin(await read($, repoPin), pin) || (await read($, isDashboard)) || (await readTarget($)) !== target
320
324
  if (token !== generation) return true
321
- if (!isMoved) return false
325
+ if (!isMoved && moves === pinMoves) return false
322
326
  wantsAnother()
323
327
  return true
324
328
  }
@@ -362,13 +366,13 @@ async function fetchMap($: Dollar, pin: RepoPin, token: number, wantsAnother: ()
362
366
 
363
367
  // The whiteboard's open tickets (map-less) and the pinned index, plus the open maps. Same drop rule
364
368
  // as a map read: a result is not committed once the pane closed or the dashboard was left.
365
- async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnother: () => void) {
369
+ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, moves: number, wantsAnother: () => void) {
366
370
  const isStale = async () => {
367
371
  if (token !== generation) return true
368
372
  const isOther = !isSamePin(await read($, repoPin), pin)
369
373
  const isLeft = !(await read($, isDashboard))
370
374
  if (token !== generation) return true
371
- if (!isOther && !isLeft) return false
375
+ if (!isOther && !isLeft && moves === pinMoves) return false
372
376
  wantsAnother()
373
377
  return true
374
378
  }
@@ -404,12 +408,14 @@ async function fetchDashboard($: Dollar, pin: RepoPin, token: number, wantsAnoth
404
408
  // holds for the old one (and drops a read of it still in flight), so its tickets never show under the new.
405
409
  let paneRepo: string | null = null
406
410
  // Called by refreshPin right before the pin atom changes, from the pane's refresh and the band's alike. A move
407
- // to another repo drops the reads in flight and empties what the pane holds for the old one, then asks for a
408
- // fresh read (a closed pane makes that a no-op), so its tickets never show under the new repo.
411
+ // to another repo (or to no pin) drops the reads in flight and, to another repo, empties what the pane holds for
412
+ // the old one, then asks for a fresh read (a closed pane makes that a no-op), so its tickets never show under the
413
+ // new repo. A move inside the same repo leaves the read in flight to its own stale check, which reads again.
409
414
  export async function onPinMove($: Dollar, pin: RepoPin | null): Promise<void> {
410
- generation += 1
415
+ pinMoves += 1
411
416
  const key = pin === null ? null : pinKey(pin)
412
417
  const isOtherRepo = paneRepo !== null && key !== null && key !== paneRepo
418
+ if (isOtherRepo || pin === null) generation += 1
413
419
  paneRepo = key ?? paneRepo
414
420
  if (!isOtherRepo) return
415
421
  await update($, mapPane, () => EMPTY)
@@ -435,25 +441,28 @@ function stopTimers() {
435
441
  generation += 1
436
442
  // A fill timer dropped with the pane must not leave the next pane unfillable.
437
443
  isFilling = false
438
- trailing?.cancel()
444
+ trailing?.timer.cancel()
439
445
  trailing = null
446
+ lastWriteAt = null
440
447
  ticker?.timer.cancel()
441
448
  ticker = null
442
449
  }
443
450
 
444
- // The slow timer past its due time by more than the grace never fired: drop it, so the caller starts another.
445
- // A dropped trailing needs no such check: the next read outside the floor clears it.
451
+ // A timer past its due time by more than the grace never fired: drop it, so the caller starts another.
452
+ // Both the slow timer and the trailing refresh are tracked, as the host may silently drop either.
446
453
  const liveOrNull = (t: Tracked | null, now: number): Tracked | null => {
447
454
  if (t === null || !isTimerLate(now, t.dueAt, TIMER_GRACE_MS)) return t
448
455
  t.timer.cancel()
449
456
  return null
450
457
  }
451
458
 
452
- const startTrailing = ($: Dollar, waitMs: number): Timer =>
453
- $.clock.after(waitMs, () => {
459
+ const startTrailing = ($: Dollar, now: number, waitMs: number): Tracked => ({
460
+ timer: $.clock.after(waitMs, () => {
454
461
  trailing = null
455
462
  void requestRefresh($)
456
- })
463
+ }),
464
+ dueAt: now + waitMs,
465
+ })
457
466
 
458
467
  const startTicker = ($: Dollar, now: number): Tracked => ({
459
468
  timer: $.clock.every(TIMER_MS, () => void requestRefresh($, { isTick: true })),
@@ -464,7 +473,9 @@ const startTicker = ($: Dollar, now: number): Tracked => ({
464
473
  // `isWrite`: a tracker write just ran; a read that starts at once is followed by one more at the floor's end,
465
474
  // by when the search index (history, the list fallback) has caught up.
466
475
  // `isTick`: the slow timer fired, so its next due time moves on.
467
- type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean }
476
+ // `isTurnEnd`: the turn is over; if a write landed after the last read started, the read runs inside the floor
477
+ // too, so the pane shows the turn's final tracker state without waiting on a timer the host may have dropped.
478
+ type Request = { bypassFloor?: boolean; isWrite?: boolean; isTick?: boolean; isTurnEnd?: boolean }
468
479
 
469
480
  // Throttled: inside the floor it keeps ONE trailing refresh for the rest of it, so an event
470
481
  // just after a refresh still shows.
@@ -472,24 +483,32 @@ async function decide($: Dollar, request: Request) {
472
483
  if (!(await isPaneUp($))) return
473
484
  // Nothing pinned: no repo to read, so no call.
474
485
  const pin = await syncPin($)
486
+ const moves = pinMoves
475
487
  if (pin === null) return
476
488
  const now = await $.clock.now()
477
489
  if (request.isTick === true && ticker !== null) ticker = { ...ticker, dueAt: now + TIMER_MS }
478
490
  // A timer the host dropped is started again here, else the pane would wait on it for good.
479
491
  ticker = liveOrNull(ticker, now) ?? startTicker($, now)
480
- const decision = request.bypassFloor === true ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
492
+ // Same for the trailing refresh: a dead handle would keep `??=` below from arming a live one.
493
+ trailing = liveOrNull(trailing, now)
494
+ // Strictly newer: a read that started at the write's own instant already saw it.
495
+ const isFlush = request.isTurnEnd === true && lastWriteAt !== null && (lastStartedAt === null || lastWriteAt > lastStartedAt)
496
+ const decision = request.bypassFloor === true || isFlush ? { run: true as const } : refreshDecision(now, lastStartedAt, MIN_REFRESH_MS)
481
497
  if (!decision.run) {
482
- trailing ??= startTrailing($, decision.waitMs)
498
+ trailing ??= startTrailing($, now, decision.waitMs)
483
499
  return
484
500
  }
485
501
  lastStartedAt = now
486
- trailing?.cancel()
487
- trailing = null
502
+ // A flush leaves the trailing refresh alone: it still owes the read after the search index caught up.
503
+ if (!isFlush) {
504
+ trailing?.timer.cancel()
505
+ trailing = null
506
+ }
488
507
  // Never bypassFloor: a burst of writes still costs one read per floor plus this one trailing.
489
- if (request.isWrite === true) trailing = startTrailing($, MIN_REFRESH_MS)
508
+ if (request.isWrite === true) trailing = startTrailing($, now, MIN_REFRESH_MS)
490
509
  const again = () => void requestRefresh($, { bypassFloor: true })
491
- if (await read($, isDashboard)) await fetchDashboard($, pin, generation, again)
492
- else await fetchMap($, pin, generation, again)
510
+ if (await read($, isDashboard)) await fetchDashboard($, pin, generation, moves, again)
511
+ else await fetchMap($, pin, generation, moves, again)
493
512
  }
494
513
 
495
514
  // Does nothing while the pane is closed: the data exists only for the pane.
@@ -876,7 +895,7 @@ export function registerMapPane(on: On) {
876
895
  const result = await next(e)
877
896
  await safeSyncPin($)
878
897
  await safeReadActiveMap($)
879
- void requestRefresh($)
898
+ void requestRefresh($, { isTurnEnd: true })
880
899
  // The band's hook marks the turn over; the timer runs after the whole chain, and releases a held press.
881
900
  if (e.agentId === undefined) $.clock.after(0, () => releaseHeld($))
882
901
  return result
@@ -903,6 +922,7 @@ export function registerMapPane(on: On) {
903
922
  // After the call ran: a Bash `gh issue` write changed the tracker.
904
923
  on('tool.call', { tool: 'Bash', command: GH_ISSUE_WRITE }, async ($, e, next) => {
905
924
  const result = await next(e)
925
+ lastWriteAt = await $.clock.now()
906
926
  void requestRefresh($, { isWrite: true })
907
927
  await hintWhiteboard($, e.command, result)
908
928
  return result
@@ -1146,8 +1166,12 @@ export function registerMapPane(on: On) {
1146
1166
 
1147
1167
  // A list older than one slow-timer period missed its reads (the pane undrawn when they came, or the
1148
1168
  // timer dropped): the render asks for one, as for an empty view. A failing read keeps its slow pace.
1169
+ // A trailing refresh past its due time by more than the grace was dropped by the host: the render asks for
1170
+ // the read it would have made (the request replaces the dead handle).
1171
+ const isTrailingLate = trailing !== null && isTimerLate(now, trailing.dueAt, TIMER_GRACE_MS)
1149
1172
  const isOverdue = (d: { fetchedAt: number | null; error: string | null; isLoading: boolean }) =>
1150
- d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS)
1173
+ isTrailingLate ||
1174
+ (d.fetchedAt !== null && d.error === null && !d.isLoading && isTimerLate(now, d.fetchedAt + TIMER_MS, TIMER_GRACE_MS))
1151
1175
 
1152
1176
  // No GitHub repo was ever pinned: say so rather than a `gh` failure; nothing is read.
1153
1177
  if (pin === null) {
@@ -663,6 +663,40 @@ const GIT_CONFIG_PARAMETERS_UNMODELLED = (sample) => ({
663
663
  sample,
664
664
  });
665
665
 
666
+ // #316: the unmodelled-hit shape for a nested shell / eval behind a command wrapper
667
+ // (`nice bash -c '...'`), whose inner text the guard does not follow.
668
+ const WRAPPED_SHELL_UNMODELLED = (sample) => ({
669
+ id: 'wrapped-nested-shell',
670
+ label: 'a command wrapper in front of a nested shell (`bash -c`) or eval',
671
+ hint: 'run the inner command directly, without the wrapper and the nested shell',
672
+ sample,
673
+ });
674
+
675
+ // #316: wrappers this gate also knows that lib/unmodelled.js does not skip. A word list
676
+ // only: the gate never walks a wrapper's options, so no option values are needed here.
677
+ const EXTRA_WRAPPER_WORDS = new Set(['stdbuf', 'time', 'setsid', 'doas']);
678
+
679
+ // #316: a statement whose command word (after NAME=value prefixes) is a known wrapper
680
+ // (lib/unmodelled.js) and that carries a nested shell word or `eval` later on. The
681
+ // wrapper's own options are not walked: any such word fails closed. Null when not.
682
+ function wrappedNestedShell(tokens) {
683
+ let lib;
684
+ try {
685
+ lib = require('./lib/unmodelled.js');
686
+ } catch {
687
+ return null; // a missing lib only switches this gate off, like unmodelledSecretPiece
688
+ }
689
+ const first = skipEnvPrefix(tokens);
690
+ if (first >= tokens.length) return null;
691
+ const word = unquoteWord(tokens[first]);
692
+ if (!lib.isWrapperWord(word) && !EXTRA_WRAPPER_WORDS.has(word)) return null;
693
+ const nested = tokens.slice(first + 1).some((t) => {
694
+ const w = basename(unquoteWord(t));
695
+ return w === 'eval' || SHELL_DASH_C_VERBS.has(w);
696
+ });
697
+ return nested ? tokens.join(' ') : null;
698
+ }
699
+
666
700
  // Scan a single top-level statement for a secret path. Recurses into a
667
701
  // bash -c/eval quoted sub-command first (finding #1), routes grep/rg/ag/sed/
668
702
  // awk through the shared pattern-skipping walk (finding #4), and falls back
@@ -701,7 +735,12 @@ function scanStatementForSecretPath(stmt) {
701
735
  // standalone docker-compose/podman-compose binaries) get --env-file's value
702
736
  // exempted; every other verb keeps today's behaviour unchanged.
703
737
  const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
704
- return scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
738
+ const plain = scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
739
+ if (plain) return plain;
740
+ // #316: only when a secret-looking piece sits in the statement (`timeout 5 bash -c 'echo hi'` stays allowed).
741
+ const wrapped = wrappedNestedShell(tokens);
742
+ const piece = wrapped && crudeSecretPiece(wrapped);
743
+ return piece ? { path: piece, unmodelled: WRAPPED_SHELL_UNMODELLED(wrapped) } : null;
705
744
  }
706
745
 
707
746
  // #214: substitution bodies are scanned off an explicit work-list (no
@@ -612,4 +612,72 @@ ok('#276 hook exits 2 on bash -c "cat \\\\.env"', runHook('Bash', { command: 'ba
612
612
 
613
613
  ok('#276 hook exits 2 on cat \\.env', runHook('Bash', { command: 'cat \\.env' }).code === 2);
614
614
 
615
+ // #316: a wrapper in front of a nested shell must not hide the inner command, and the
616
+ // value of grep/sed/awk -f / --file is a FILE operand (a pattern/script file), not a skipped value.
617
+ for (const command of [
618
+ "nice bash -c 'cat .env'",
619
+ "timeout 5 bash -c 'cat .env'",
620
+ "nice -n 5 bash -c 'cat .env'",
621
+ "env -i bash -c 'cat .env'",
622
+ "nohup bash -c 'cat .env'",
623
+ "sudo bash -c 'cat .env'",
624
+ "sudo -u root bash -c 'cat .env'",
625
+ ]) {
626
+ const r = detectSecretRead('Bash', { command });
627
+ ok(`#316 deny (wrapper + nested shell, unmodelled): ${command}`, !!r && !!r.unmodelled);
628
+ ok(`#316 hook exits 2 UNMODELLED: ${command}`, (() => { const h = runHook('Bash', { command }); return h.code === 2 && JSON.parse(h.out).code === 'SECRET_READ_UNMODELLED_CONSTRUCT'; })());
629
+ }
630
+ for (const command of [
631
+ 'grep -f .env pat f',
632
+ 'grep -f.env pat f',
633
+ 'grep -if.env pat f',
634
+ 'grep --file=.env pat f',
635
+ 'grep --file .env pat f',
636
+ 'sed -f .env x',
637
+ 'awk -f .env x',
638
+ ]) {
639
+ ok(`#316 deny (-f / --file value is a file): ${command}`, !!detectSecretRead('Bash', { command }));
640
+ }
641
+ // #316: the rest of the ticket's wrapper list, which lib/unmodelled.js does not skip.
642
+ for (const command of [
643
+ "stdbuf -oL bash -c 'cat .env'",
644
+ "time bash -c 'cat .env'",
645
+ "setsid bash -c 'cat .env'",
646
+ "doas bash -c 'cat .env'",
647
+ ]) {
648
+ const r = detectSecretRead('Bash', { command });
649
+ ok(`#316 deny (wrapper + nested shell, unmodelled): ${command}`, !!r && !!r.unmodelled);
650
+ }
651
+ // #316: with -f the pattern/script comes from a file, so the first positional is a FILE.
652
+ for (const command of [
653
+ 'grep -f pats .env',
654
+ 'grep --file=pats .env',
655
+ 'sed -f script .env',
656
+ 'awk -f prog .env',
657
+ ]) {
658
+ ok(`#316 deny (positional after -f is a file): ${command}`, !!detectSecretRead('Bash', { command }));
659
+ }
660
+ for (const command of [
661
+ "timeout 5 bash -c 'echo hi'",
662
+ 'nice -n 5 ls',
663
+ 'grep -f patterns.txt x f',
664
+ 'grep -e foo f',
665
+ 'grep -ef.env x',
666
+ ]) {
667
+ ok(`#316 allow: ${command}`, !detectSecretRead('Bash', { command }));
668
+ }
669
+ for (const command of [
670
+ 'cat .env',
671
+ "bash -c 'cat .env'",
672
+ "sh -c 'cat .env'",
673
+ "eval 'cat .env'",
674
+ 'grep x .env',
675
+ 'stdbuf -oL cat .env',
676
+ 'exec cat .env',
677
+ 'sudo -u root cat .env',
678
+ "nice eval 'cat .env'",
679
+ ]) {
680
+ ok(`#316 deny (unchanged): ${command}`, !!detectSecretRead('Bash', { command }));
681
+ }
682
+
615
683
  console.log(`✓ secret-read-guard: ${passed} assertions passed`);
@@ -48,7 +48,7 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
48
48
 
49
49
  **Inline update check (hookless hosts).** On a host without a hook bus (the descriptor's `hookBus == 'none'`) the `trailhead-check-update.js` SessionStart hook and the statusline flag are both absent, so the engine does the check **inline at session start**, co-located with the models-collapse notice above. It is lightweight and degrades silently: read the shared cache (`$XDG_CACHE_HOME/trailhead/update-check.json`, the same file the hook writes on Claude Code) and, if it reports an update, surface a one-line notice pointing at `/trailhead:update` (the pure `updateNotice(host, cache)` helper in `bin/lib/host-descriptor.js` is the canonical wording, and returns null on a host that already notifies via the hook, so it never double-fires); if the cache is absent or stale (older than a few hours), do a minimal throttled compare of the installed `VERSION` against the latest (npm registry or the GitHub release, per install channel) and rewrite the cache **atomically** (write a temp file in the same directory as the target, then rename it over the target, exactly as the `trailhead-check-update.js` SessionStart hook does with `out + '.tmp'` then `renameSync`), so a crash mid-write cannot leave a truncated or corrupt cache. Never block or fail the session on a network or parse error. `/trailhead:update` stays the on-demand installer.
50
50
 
51
- **Pinned model review.** After an update that changed the tier defaults, the engine offers to review pinned `models.*` / `models.codex.*` ids that are older than their tier's new default (listed `key: old -> new`), with three choices: update (write the new ids, codex effort unchanged), keep (nothing to do, not asked again for these defaults), or open `/trailhead:config` to pick per key. The review evaluates the **effective** pins: the global `~/.claude/trailhead/config.json` merged under the project's `.trailhead/config.json`, project winning key by key (a project's own key, even `null`, `''`, or `inherit session`, always wins over the global value; `models.codex` follows the same per-key rule, so a stale pin inherited from the global config is flagged too, even for a project that pins nothing itself). Each stale line names the config file it actually came from, so the update choice edits the right one. The check (`model-defaults-review.js`'s `check(cwd)`, CLI subcommand `check`) is **read-only**: it never writes an ack by itself, so it keeps reoffering until the user actually answers. The ack (CLI subcommand `ack`, writing the acknowledged defaults version) is written only after the user picks one of the three choices, never just for the notice having been shown; it lives in the gitignored `.trailhead/model-defaults-ack` file when a project is found above `cwd`, whether the stale pin itself is project or global. With no project found above `cwd`, the review runs on the global config alone (if one exists) and its ack lives next to it, at `<config dir>/trailhead/model-defaults-ack` (config dir = `$CLAUDE_CONFIG_DIR` or `~/.claude`). The check runs both in the `trailhead-check-update.js` SessionStart hook and at skill config load (`../_shared/load-first.md`), once per session either way. Unset keys already follow the new defaults, so they are never flagged; unknown ids (custom or unrecognised) are never flagged either. The tier history lives in `hooks/lib/model-defaults.json`. Hookless hosts run the same `node <hooks>/lib/model-defaults-review.js check <dir>` / `... ack <dir>` pair inline at session start.
51
+ **Pinned model review.** After an update that changed the tier defaults, the engine offers to review pinned `models.*` / `models.codex.*` ids that are older than their tier's new default (listed `key: old -> new`), with three choices: update (write the new ids, codex effort unchanged), keep (nothing to do, not asked again for these defaults), or open `/trailhead:config` to pick per key. The review evaluates the **effective** pins: the global config (`~/.claude/trailhead/config.json` on Claude Code, `<codexHome>/trailhead/config.json` on Codex) merged under the project's `.trailhead/config.json`, project winning key by key (a project's own key, even `null`, `''`, or `inherit session`, always wins over the global value; `models.codex` follows the same per-key rule, so a stale pin inherited from the global config is flagged too, even for a project that pins nothing itself). Each stale line names the config file it actually came from, so the update choice edits the right one. The check (`model-defaults-review.js`'s `check(cwd)`, CLI subcommand `check`) is **read-only**: it never writes an ack by itself, so it keeps reoffering until the user actually answers. The ack (CLI subcommand `ack`, writing the acknowledged defaults version) is written only after the user picks one of the three choices, never just for the notice having been shown; it lives in the gitignored `.trailhead/model-defaults-ack` file when a project is found above `cwd`, whether the stale pin itself is project or global. With no project found above `cwd`, the review runs on the global config alone (if one exists) and its ack lives next to it, at `<config dir>/trailhead/model-defaults-ack` (config dir = `$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code; on Codex it is `<codexHome>`, derived from the installed lib path `<codexHome>/skills/trailhead/hooks/lib`, falling back to `CODEX_HOME` or `~/.codex` only when the lib is not at that path). The check runs both in the `trailhead-check-update.js` SessionStart hook and at skill config load (`../_shared/load-first.md`), once per session either way. Unset keys already follow the new defaults, so they are never flagged; unknown ids (custom or unrecognised) are never flagged either. The tier history lives in `hooks/lib/model-defaults.json`. Hookless hosts run the same `node <hooks>/lib/model-defaults-review.js check <dir>` / `... ack <dir>` pair inline at session start.
52
52
 
53
53
  If a value is unset, it resolves to its **Balanced**-profile default for that activity (the default profile: a project with no config still gets the Balanced tier per key, resolved to a concrete id at load time, strong → `claude-opus-5-5`, standard → `claude-sonnet-5-5`, fast → `claude-haiku-5-5`). To use the session model instead of the Balanced default, set the key explicitly to `inherit session`. Never hard-fail on an unknown id: warn and inherit the session model.
54
54
 
@@ -128,7 +128,7 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
128
128
 
129
129
  **Anything but Off has mandatory follow-ups, never skip them:** after the CLIs are settled (whether via *All detected* or *Pick specific*), ask **🔁 rounds** (default 2). Off is the only branch with no follow-up; every other path asks rounds explicitly rather than taking the default.
130
130
  10. **📊 Statusline**: offer trailhead's **Claude Code status bar**, and **say in the prompt what it shows** so the choice is informed. It renders one line: **model · project · branch · plan usage (`5h %` · `⏳ reset` · `7d %`) · a context-window bar (`362k/1000k (45%)`)**, plus a **second line with the active ticket** (`▸ #N Title`) whenever one is in play (from the `trailhead/t<n>` branch or the `.trailhead/session-ticket` marker). The plan-usage % comes from Anthropic's own usage endpoint (the same source the Claude UI uses); the context bar and ticket are computed locally. Options: ⛔ **Off** (default; leave your statusline untouched) · ✅ **Enable**.
131
- - On **Enable**, install it: copy the shipped script (`${CLAUDE_PLUGIN_ROOT}/templates/statusline-trailhead.sh` for a plugin install, or `~/.claude/trailhead/templates/statusline-trailhead.sh` for an npm install, whichever exists) to `~/.claude/statusline-trailhead.sh` (executable), then set `statusLine` in `~/.claude/settings.json` to `{ "type": "command", "command": "bash \"$HOME/.claude/statusline-trailhead.sh\"" }`. **This is a global Claude Code setting, not per-project**, so it always lands in `~/.claude/settings.json` regardless of the scope chosen in step 1.
131
+ - On **Enable**, install it: copy the shipped script (`${CLAUDE_PLUGIN_ROOT}/templates/statusline-trailhead.sh` for a plugin install, or `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/statusline-trailhead.sh` for an npm install, whichever exists) to `${CLAUDE_CONFIG_DIR:-~/.claude}/statusline-trailhead.sh` (executable), then set `statusLine` in `${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json` to `{ "type": "command", "command": "bash \"<config dir>/statusline-trailhead.sh\"" }`, where `<config dir>` is the config directory resolved to an absolute path at install time (so the command works whatever environment Claude Code runs it with). **This is a global Claude Code setting, not per-project**, so it always lands in the config dir's `settings.json` regardless of the scope chosen in step 1.
132
132
  - **If a `statusLine` is already configured** (e.g. `ccstatusline`), **do not clobber it silently**: show what it is and ask whether to **replace** it with trailhead's, or **keep** the current one (in which case, mention that the script also supports single-segment widgets, `--ticket-only` / `--context-only` / `--usage-only`, to add into an existing statusline tool). Needs `jq` and `python3` on the PATH; if either is missing, say so and skip the enable rather than wiring a broken command.
133
133
 
134
134
  Keep the icons stable so the menus read the same each run. `config get` shows the same fields as a compact icon summary, read-only.
@@ -14,7 +14,7 @@ Resolving a ticket **ends the session**: one ticket per session is a hard rule (
14
14
 
15
15
  Name the next frontier ticket so the user knows what they're taking; put its **number** in the command (never the title). **When the frontier holds several mutually independent tickets, don't crown one: present them as a set to choose from**, naming each with its number (see **Frontier order carries no priority**). Bare `/trailhead:work` still acts on one, but the block keeps the choice visible. The block is the list of things to do **after the `/clear`** (it leads with it) and stays on screen in the terminal to read from, so each command must be either self-sufficient (a number already known) or safe to type by hand. **This self-sufficiency governs *every* line after `/clear`, not only the commands: never sequence after `/clear` an action the *agent* must perform that depends on context `/clear` will erase** (an "I'll open the ticket for you" gated on a decision taken this session). `/clear` wipes that context, so the agent cannot honour it and the user is stuck: clearing loses what the step needs, while not clearing violates the `/clear`-first rule. When an agent-side follow-up is genuinely needed, resolve it one of two ways: **do the agent action *before* the handoff** (perform it now, then render the `/clear`-first block pointing at its result), or **convert the next step into a user-runnable command** (`/trailhead:work <n>` with the resulting number, `/trailhead:map` for the user to choose, or the diverge-first `/trailhead:ticket` framing); never "I'll do it after you clear." The commands a handoff block may contain are `/clear`, `/trailhead:work` (bare or `<number>`), `/trailhead:map`, `/trailhead:dashboard`, and `/trailhead:quick`; **in a whiteboard session** (the resolved ticket was `trailhead:whiteboard`), also `/trailhead:whiteboard`. **Always offer the read-only views alongside the next action**: `/trailhead:map` for the low-res map and `/trailhead:dashboard` for the repo-wide index (all maps + the whiteboard), so the user can look before choosing. **`/trailhead:quick` is the one command that may carry a free-text argument here**: `/trailhead:quick "<short seed>"` when a concrete off-map piece is actually at hand (its argument is forgiving freeform seed text, not an exact title to match, so pre-filling it is helpful, not error-prone), or **bare `/trailhead:quick`** as the generic "if you want to work a piece off the map" offer, which prompts for the piece itself; keep any seed short and quote-safe. **Describe `work` and `quick` correctly wherever the block characterizes them:** `work` is the full cycle (Discuss -> Plan -> Execute -> Verify, with TDD during Execute per `config.tdd`); `quick <n>` works that same existing ticket whole via the lighter, no-split path (always size-triaged), a map ticket stays on its map; only `quick "<text>"` opens a new ticket off the map (the whiteboard). Never describe `quick <n>` on a map ticket as off the map. Neither is "the split command" (that is the separate `split` verb), so never frame `work` as splitting. Anchor `/trailhead:work <n>` first and offer `/trailhead:quick <n>` alongside as the lighter alternative, never in its place and never rendered twice on one line. **Characterize `quick` by its purpose, the lighter, faster path** (always size-triaged: on a small change it offers to skip the heavy steps on an explicit yes; grills only if needed; no map book-keeping), **never as merely "without splitting"**: not splitting is a side effect, not what `quick` is for. **`/trailhead:work` always takes the number, never the title** of an existing ticket, and **never hand the user a `/trailhead:ticket <type> "<title>"`** (or any other title-bearing command): adding a map ticket needs the diverge-first framing (Mode 3), so it is never pre-filled.
16
16
 
17
- **If the frontier is empty**, that usually means fog that this resolution made specifiable should have been **graduated into a ticket already** (Mode 2 step 6), so the frontier isn't actually empty. If it genuinely is (nothing ticketable yet, or the user must choose which fog item to take next), point the block to **`/trailhead:map`** and offer to open the next ticket *yourself* once they say which one (named by its number in the numbered `/trailhead:map` fog list), rather than pre-filling a `/trailhead:ticket` command for them. **Fog still on the map takes priority over loose whiteboard work.** If the map still has fog (unspecified areas in *Not yet specified*), lead with **`/trailhead:map`** to dissolve it (its fog list numbers each entry, so a specific point can be named by number): that fog is the map's own remaining work, so graduating it comes before any off-map ticket. **Only when the map is genuinely complete** (frontier empty *and* no fog left) and the whiteboard still holds open loose tickets, do not sign off as if nothing remains: check for them and, if any exist, **surface them**, pointing the block at **`/trailhead:whiteboard`** (or **`/trailhead:dashboard`** for the full index) and naming the loose tickets, anchored on **`/trailhead:work <n>`** (the full cycle Discuss -> Plan -> Execute -> Verify; for a whiteboard ticket it works the ticket whole, and split stays available only if it proves oversized, but `work` is not itself the split command) with `/trailhead:quick <n>` offered alongside as the lighter alternative (the same ticket, worked whole via the lighter path), never in its place. **In a whiteboard session** the fog-graduation reasoning does not apply (the whiteboard has no fog): point the block at **`/trailhead:whiteboard`**, anchored on **`/trailhead:work <n>`** for the next loose ticket (the full cycle; split stays available only if it proves oversized) with `/trailhead:quick <n>` alongside as the lighter alternative (the same ticket, worked whole via the lighter path), not the map. (The example is Italian; match whatever language you're conversing in.)
17
+ **If the frontier is empty**, that usually means fog that this resolution made specifiable should have been **graduated into a ticket already** (Mode 2 step 6), so the frontier isn't actually empty. Before pointing anywhere, run the **Ticket or fog** test (charting, `../trailhead-chart/references/charting.md`, Mode 1 step 4) on every fog entry and, when some pass, offer to graduate them as **one batch** (title, type, blockers per ticket, created on a yes) so the next sessions have a frontier to work in a row instead of one fog entry at a time. If it genuinely is empty (no entry passes the test, or the user must choose which fog item to take next), point the block to **`/trailhead:map`** and offer to open the next ticket *yourself* once they say which one (named by its number in the numbered `/trailhead:map` fog list), rather than pre-filling a `/trailhead:ticket` command for them. **Fog still on the map takes priority over loose whiteboard work.** If the map still has fog (unspecified areas in *Not yet specified*), lead with **`/trailhead:map`** to dissolve it (its fog list numbers each entry, so a specific point can be named by number): that fog is the map's own remaining work, so graduating it comes before any off-map ticket. **Only when the map is genuinely complete** (frontier empty *and* no fog left) and the whiteboard still holds open loose tickets, do not sign off as if nothing remains: check for them and, if any exist, **surface them**, pointing the block at **`/trailhead:whiteboard`** (or **`/trailhead:dashboard`** for the full index) and naming the loose tickets, anchored on **`/trailhead:work <n>`** (the full cycle Discuss -> Plan -> Execute -> Verify; for a whiteboard ticket it works the ticket whole, and split stays available only if it proves oversized, but `work` is not itself the split command) with `/trailhead:quick <n>` offered alongside as the lighter alternative (the same ticket, worked whole via the lighter path), never in its place. **In a whiteboard session** the fog-graduation reasoning does not apply (the whiteboard has no fog): point the block at **`/trailhead:whiteboard`**, anchored on **`/trailhead:work <n>`** for the next loose ticket (the full cycle; split stays available only if it proves oversized) with `/trailhead:quick <n>` alongside as the lighter alternative (the same ticket, worked whole via the lighter path), not the map. (The example is Italian; match whatever language you're conversing in.)
18
18
 
19
19
  **Pending codebase offer (greenfield): resolve it *before* this block, exactly once.** If this session's Work-the-map step 1 marked a greenfield codebase-issue offer **pending** (the repo has no `trailhead:codebase` issue but now holds substantial code), surface the offer and resolve it **before rendering the `/clear`-first next-step block**, never as a line inside that block. This is deliberate: its "yes" triggers an **agent action** (generate and pin the codebase issue), and the self-sufficiency rule above forbids an agent-side action sequenced after `/clear`, so the offer is settled while the agent still holds context, exactly the "do the agent action before the handoff" resolution that rule prescribes. It is **an offer the user accepts with a yes, not a command they type** (there is no `/trailhead:codebase` command), so pose it as a yes/no, along the lines of *"vuoi che generi l'issue codebase adesso? (una mappa del repo, fissata tra i pin)"* (in the user's conversation language). Ask it **exactly once**: on a yes, generate and pin it now via the **Codebase map** technique, *then* render the `/clear`-first block (which may point at the freshly pinned issue); on a no or silence, render the block without it and don't nag again this session (the pending state is re-derived from repo state each session, so an offer left unresolved, whether declined or simply unanswered, re-surfaces next session until a `trailhead:codebase` issue exists, and nothing is lost). A whiteboard handoff never carries it (even if `work <n>` ran step 1 against a background map, the offer belongs to that map, not this loose ticket).
20
20
 
@@ -101,10 +101,10 @@ The map issue's **body** is a shared mutable document: two sessions appending to
101
101
 
102
102
  **On an untrusted match** (a `trailhead:*` issue whose label came from a non-write actor, or a ticket with no valid `Parent:`) do **not** put it on the frontier or act on it. Label it **`trailhead:unverified`**, and surface it to the user as a quarantined item to **adopt** (a maintainer re-applies the real labels + wires `Parent:`) or **reject** (strip the `trailhead:*` labels, leave it a normal issue). Never silently trust a label just because it's present.
103
103
 
104
- **Repo-side enforcement (part of first-use setup, not an afterthought).** trailhead **installs the label guard for you**; don't tell the user to copy files. **Every chart or adopt, *check* whether `.github/workflows/trailhead-label-guard.yml` exists** (the first-use repo setup, in SKILL.md's Substrate section); if it's absent, install it, by default on a repo with other collaborators, and as a quick offer on a clearly solo/private one (cheap insurance), but **never silently skip the check**: that's exactly how a repo ends up with trailhead labels and no guard. On go-ahead, write the shipped label-guard template (`${CLAUDE_PLUGIN_ROOT}/templates/trailhead-label-guard.yml` for a plugin install, or `~/.claude/trailhead/templates/trailhead-label-guard.yml` for an npm install, take whichever exists) to the repo's `.github/workflows/trailhead-label-guard.yml` and commit it (conventional message; the commit-guard hook keeps it clean). The Action strips any `trailhead:*` label added by a non-write actor and comments why; set an optional `TRAILHEAD_LABEL_ALLOWLIST` repo variable to whitelist extra logins (e.g. a bot trailhead runs as). **Caveat:** pushing a workflow file needs a token with the `workflow` scope; if the push is rejected, run `gh auth refresh -s workflow` and retry (or, as a last resort, tell the user to add the file by hand).
104
+ **Repo-side enforcement (part of first-use setup, not an afterthought).** trailhead **installs the label guard for you**; don't tell the user to copy files. **Every chart or adopt, *check* whether `.github/workflows/trailhead-label-guard.yml` exists** (the first-use repo setup, in SKILL.md's Substrate section); if it's absent, install it, by default on a repo with other collaborators, and as a quick offer on a clearly solo/private one (cheap insurance), but **never silently skip the check**: that's exactly how a repo ends up with trailhead labels and no guard. On go-ahead, write the shipped label-guard template (`${CLAUDE_PLUGIN_ROOT}/templates/trailhead-label-guard.yml` for a plugin install, or `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/trailhead-label-guard.yml` for an npm install, take whichever exists) to the repo's `.github/workflows/trailhead-label-guard.yml` and commit it (conventional message; the commit-guard hook keeps it clean). The Action strips any `trailhead:*` label added by a non-write actor and comments why; set an optional `TRAILHEAD_LABEL_ALLOWLIST` repo variable to whitelist extra logins (e.g. a bot trailhead runs as). **Caveat:** pushing a workflow file needs a token with the `workflow` scope; if the push is rejected, run `gh auth refresh -s workflow` and retry (or, as a last resort, tell the user to add the file by hand).
105
105
 
106
106
  **Keeping the guard current (migration).** The guard filters at **job level** (`jobs.guard.if`: a `trailhead:` label, a sender other than the repo owner, not in `TRAILHEAD_LABEL_ALLOWLIST`), because a skipped job is not billed while a job that starts and returns early from its script costs at least one Actions minute; the in-script permission check stays the real defence. Guards installed before 0.12.1 lack that `if:` and start a runner on **every** label event, which with trailhead labelling all the time runs into thousands of billed runs a month. So whenever the guard is present (every chart/adopt, and after `/trailhead:update`), run `node <templates>/trailhead-label-guard-sync.js --repo <working-root>` (same `<templates>` lookup as the commit-msg sync script below). It prints one JSON status line: `current` (nothing to do), `outdated` (offer the upgrade and always explain why in the question, in plain words: today every label added to an issue starts a run billed at least one minute even when it does nothing, trailhead labels constantly so that adds up to thousands of billed runs a month and can exhaust the Actions minutes, and the updated guard skips irrelevant events before a runner starts with the same protection), `custom` (has the job-level filter but differs from the template: leave it, mention it only if asked), `foreign` (not a trailhead workflow: never touched), `absent` (install as above). On go-ahead for `outdated`, re-run it with `--apply` (it rewrites the file from the template in the working tree only, never a foreign or customised guard), then commit it (`fix: skip trailhead label guard job for irrelevant label events`) and push, with the same `workflow`-scope caveat. The SessionStart hook (or, on a host with no hook bus, the load-first check) also runs it with `--notice` at every session start and surfaces the same upgrade offer until the user answers it; `--ack` records the answer in the gitignored `.trailhead/label-guard-ack` (the guard's hash), so a declined offer comes back only if the file changes. If the script is missing (an outdated install), fall back to reading the file and checking `jobs.guard` for an `if:` containing `startsWith(github.event.label.name, 'trailhead:')`.
107
107
 
108
- **The commit-msg git hook (host-independent commit guard).** On every host trailhead ALSO installs a git **`commit-msg`** hook as a host-independent backstop (a small `sh` wrapper, with its logic installed beside it as `trailhead-commit-msg.cjs`: a `.cjs` is always CommonJS, so a repo whose `package.json` says `"type": "module"` cannot break it; the wrapper never blocks a commit on a startup failure such as node or the `.cjs` missing, and then prints a one-line `trailhead: commit-msg hook inactive (<reason>)` notice to stderr) (defence in depth alongside the host's own `PreToolUse` guard on Claude Code and Codex alike), enforcing the two rules (a Conventional Commits subject of at most 72 chars; never a `Co-Authored-By` trailer) plus, while a `.trailhead/session-ticket` marker is present at the worktree root, a `Refs: #<n>` trailer matching its number, on every `git commit`, whatever host drives it (decision #16). **Every chart or adopt, and at every `work`/`quick` ticket start, run the sync script**: `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains `trailhead-commit-msg-sync.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty (it is often absent from the Bash environment), and never stop at the first miss. It prints one JSON status line and handles every case itself: absent -> installs it; an older trailhead hook (the legacy single-file JS hook, or an outdated wrapper or logic file; recognised by its own header line, not by trusting the filename) -> upgraded in place to the current layout; already current -> left alone; a foreign (non-trailhead) hook -> never touched, only reported (offer to chain it); under a repo's `core.hooksPath`, an existing trailhead hook there is still upgraded in place, but nothing is newly added to that directory. Unlike the label guard, `.git/hooks/` is not version-controlled, so this is a per-clone local install with nothing to commit. **If the sync script is missing (none of the three dirs has it: an outdated install) or node errors, never claim git enforces nothing before looking**: resolve the effective hooks dir with `git -C <working-root> rev-parse --git-path hooks` (resolve a relative output against the working root; it honours `core.hooksPath` and the git common dir shared by linked worktrees), read `<hooks dir>/commit-msg`, and classify it by its header line (a trailhead hook has `# trailhead commit-msg hook (git).` (the `sh` wrapper) or, for the legacy single-file JS hook, `// trailhead commit-msg hook (git).` among its first 5 lines, trimmed; never trust the filename alone; a symlinked `commit-msg` counts as foreign, as the script treats it). The wrapper also needs `<hooks dir>/trailhead-commit-msg.cjs` beside it: without it the wrapper lets every commit through, so treat a missing `.cjs` (or a legacy JS hook, which breaks in `"type": "module"` repos) as present-but-stale: suggest updating the install so the sync script can repair it. Report one result-oriented line: (a) trailhead hook present and executable (`test -x`): git does enforce trailhead's rules here (provided node is on the PATH git runs with; a GUI git client may lack it, and then the wrapper prints the inactive notice on each commit), but its freshness could not be checked or upgraded, so suggest updating the trailhead install; a trailhead hook that is NOT executable: git skips it, so it does not enforce trailhead's rules here (suggest updating the install so the sync script can fix the mode); (b) a foreign hook (or a symlink): left untouched, git does not enforce trailhead's rules here; (c) no hook: git does not enforce trailhead's rules here (suggest updating the install so the sync script can install it). Never blocks the work.
108
+ **The commit-msg git hook (host-independent commit guard).** On every host trailhead ALSO installs a git **`commit-msg`** hook as a host-independent backstop (a small `sh` wrapper, with its logic installed beside it as `trailhead-commit-msg.cjs`: a `.cjs` is always CommonJS, so a repo whose `package.json` says `"type": "module"` cannot break it; the wrapper never blocks a commit on a startup failure such as node or the `.cjs` missing, and then prints a one-line `trailhead: commit-msg hook inactive (<reason>)` notice to stderr) (defence in depth alongside the host's own `PreToolUse` guard on Claude Code and Codex alike), enforcing the two rules (a Conventional Commits subject of at most 72 chars; never a `Co-Authored-By` trailer) plus, while a `.trailhead/session-ticket` marker is present at the worktree root, a `Refs: #<n>` trailer matching its number, on every `git commit`, whatever host drives it (decision #16). **Every chart or adopt, and at every `work`/`quick` ticket start, run the sync script**: `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains `trailhead-commit-msg-sync.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty (it is often absent from the Bash environment), and never stop at the first miss. It prints one JSON status line and handles every case itself: absent -> installs it; an older trailhead hook (the legacy single-file JS hook, or an outdated wrapper or logic file; recognised by its own header line, not by trusting the filename) -> upgraded in place to the current layout; already current -> left alone; a foreign (non-trailhead) hook -> never touched, only reported (offer to chain it); under a repo's `core.hooksPath`, an existing trailhead hook there is still upgraded in place, but nothing is newly added to that directory. Unlike the label guard, `.git/hooks/` is not version-controlled, so this is a per-clone local install with nothing to commit. **If the sync script is missing (none of the three dirs has it: an outdated install) or node errors, never claim git enforces nothing before looking**: resolve the effective hooks dir with `git -C <working-root> rev-parse --git-path hooks` (resolve a relative output against the working root; it honours `core.hooksPath` and the git common dir shared by linked worktrees), read `<hooks dir>/commit-msg`, and classify it by its header line (a trailhead hook has `# trailhead commit-msg hook (git).` (the `sh` wrapper) or, for the legacy single-file JS hook, `// trailhead commit-msg hook (git).` among its first 5 lines, trimmed; never trust the filename alone; a symlinked `commit-msg` counts as foreign, as the script treats it). The wrapper also needs `<hooks dir>/trailhead-commit-msg.cjs` beside it: without it the wrapper lets every commit through, so treat a missing `.cjs` (or a legacy JS hook, which breaks in `"type": "module"` repos) as present-but-stale: suggest updating the install so the sync script can repair it. Report one result-oriented line: (a) trailhead hook present and executable (`test -x`): git does enforce trailhead's rules here (provided node is on the PATH git runs with; a GUI git client may lack it, and then the wrapper prints the inactive notice on each commit), but its freshness could not be checked or upgraded, so suggest updating the trailhead install; a trailhead hook that is NOT executable: git skips it, so it does not enforce trailhead's rules here (suggest updating the install so the sync script can fix the mode); (b) a foreign hook (or a symlink): left untouched, git does not enforce trailhead's rules here; (c) no hook: git does not enforce trailhead's rules here (suggest updating the install so the sync script can install it). Never blocks the work.
109
109
 
110
- **Work state file (the ticket in play, for tooling).** Alongside the session-ticket marker, trailhead keeps a gitignored `.trailhead/work-state.json` at the working root recording the one ticket in play: `{map, ticket, title, step, auto, autoDone, session, updatedAt}` (`autoDone` is the number of tickets resolved so far in an autonomous run, `0` otherwise). It is independent of `session-ticket` and of the commit-msg hook, and written the same way on Claude Code and Codex by the template helper `trailhead-work-state.js`, found with the same three-dir `<templates>` lookup as the commit-msg sync above (the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains it). Forms: `node <templates>/trailhead-work-state.js set --repo <working-root> --map <n|none> --ticket <n> --title "<title>" --step discuss` at work-start (`--map none` for a whiteboard ticket), `node <templates>/trailhead-work-state.js set --repo <working-root> --step <step>` at each step change (`discuss`, `plan`, `execute`, `verify`, `resolve`, `paused`; it merges onto the file, and a different `--ticket` starts it fresh; it needs a ticket on file, so where the file may be absent, as at resume or a pause in a fresh workspace, pass the full work-start form with that step), and `node <templates>/trailhead-work-state.js clear --repo <working-root>` at Resolve, which removes both the work state file and the `.trailhead/session-ticket` marker in one call (each removal attempted independently; it prints `sessionTicket: removed|absent|skipped` next to the usual status). During an autonomous run every `set` adds `--auto true --auto-done <n>`, `n` being the tickets resolved so far in this run. The step-to-state mapping per ticket type lives in `trailhead-work/references/ticket-engines.md`. A missing helper or a non-zero exit is silent and never blocks the work (no user-facing line).
110
+ **Work state file (the ticket in play, for tooling).** Alongside the session-ticket marker, trailhead keeps a gitignored `.trailhead/work-state.json` at the working root recording the one ticket in play: `{map, ticket, title, step, auto, autoDone, session, updatedAt}` (`autoDone` is the number of tickets resolved so far in an autonomous run, `0` otherwise). It is independent of `session-ticket` and of the commit-msg hook, and written the same way on Claude Code and Codex by the template helper `trailhead-work-state.js`, found with the same three-dir `<templates>` lookup as the commit-msg sync above (the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains it). Forms: `node <templates>/trailhead-work-state.js set --repo <working-root> --map <n|none> --ticket <n> --title "<title>" --step discuss` at work-start (`--map none` for a whiteboard ticket), `node <templates>/trailhead-work-state.js set --repo <working-root> --step <step>` at each step change (`discuss`, `plan`, `execute`, `verify`, `resolve`, `paused`; it merges onto the file, and a different `--ticket` starts it fresh; it needs a ticket on file, so where the file may be absent, as at resume or a pause in a fresh workspace, pass the full work-start form with that step), and `node <templates>/trailhead-work-state.js clear --repo <working-root>` at Resolve, which removes both the work state file and the `.trailhead/session-ticket` marker in one call (each removal attempted independently; it prints `sessionTicket: removed|absent|skipped` next to the usual status). During an autonomous run every `set` adds `--auto true --auto-done <n>`, `n` being the tickets resolved so far in this run. The step-to-state mapping per ticket type lives in `trailhead-work/references/ticket-engines.md`. A missing helper or a non-zero exit is silent and never blocks the work (no user-facing line).
@@ -20,7 +20,7 @@ Review on four axes:
20
20
 
21
21
  Classify each finding: **Critical** (bug/security/breaks the spec) → **Warning** (should fix) → **Info** (judgement call). Before reporting ANY finding, **verify it adversarially**: re-read the actual code, confirm the failing path is reachable and the claim holds. Discard plausible-but-wrong findings; an unverified finding is worse than none. Quote the offending hunk (and the standard/spec line) per finding. **Stable finding IDs**: each verified finding gets an ID the first time it's reported, `C1`, `W1`, `I1`, ..., by severity, numbered in discovery order, and keeps that ID across every round of the fix-and-re-review loop below; a re-review that finds the same issue still open reuses its ID, a genuinely new finding takes the next number in its severity. An ID's severity is fixed at first report: if a re-review reclassifies a finding to a different severity, mark the old ID `reclassified as <new ID>` in the ledger and continue the finding under a new ID of the new severity; a `reclassified` line is a pointer, not a disposition, and is left out of the counts, so the finding counts once, under its current severity. Return the report (the agent never posts it; the orchestrator posts it as the `VERIFY` comment after the report-status check), **led by a machine-checkable status tag on its own first line**: `status: clean` when no **Critical** finding remains (nothing blocks Resolve), or `status: issues_found` when one or more Criticals are still open (the caller must iterate the fix-and-re-review loop below, or run the HITL checkpoint, before resolving). Warnings/Info alone leave the tag `status: clean`, since they don't gate resolution. Emit that line verbatim as the report's first line.
22
22
 
23
- **Counts line, right under `status:`.** The comment's second line, right under `status:`, is the cumulative severity count: `critical: <n> · warning: <n> · info: <n>` (zeros written out, never omitted), the count of distinct findings (by ID, excluding `reclassified` pointers) reported on this ticket so far, across every round. Cumulative means a finding fixed in an earlier round still counts here, so the totals always match the ledger below. Below the counts comes the **findings ledger** (authoritative for this ticket), one line per finding ID with its disposition: `fixed (<commit>)`, `deferred → <ticket>` (a ticket that exists, named and linked: an already-open ticket, or one opened on the user's explicit yes per **Scope that surfaces while working a ticket** (served by the capture cluster), never unasked; when the user declines or leaves it unanswered, and under an autonomous run, where no ticket is opened unasked, use `accepted: <one-line reason>` instead), `accepted: <one-line reason>`, or `open` while the disposition is still undecided. The ledger lives in the latest `VERIFY` comment of the ticket and is rewritten each round; the resolution comment links to it rather than duplicating it. Then the findings grouped by severity with file:line and the fix, as before, so the Verify step branches on the tag without parsing prose.
23
+ **Counts line, right under `status:`.** The comment's second line, right under `status:`, is the cumulative severity count: `critical: <n> · warning: <n> · info: <n>` (zeros written out, never omitted), the count of distinct findings (by ID, excluding `reclassified` pointers) reported on this ticket so far, across every round. Cumulative means a finding fixed in an earlier round still counts here, so the totals always match the ledger below. Below the counts comes the **findings ledger** (authoritative for this ticket), one line per finding ID with its disposition: `fixed (<commit>)`, `deferred → <ticket>` (a ticket that exists, named and linked: an already-open ticket, or one opened on the user's explicit yes per **Scope that surfaces while working a ticket** (served by the capture cluster), never unasked; when the user declines or leaves it unanswered, and under an autonomous run, where no ticket is opened unasked, use `accepted: <one-line reason>` instead), `accepted: <one-line reason>`, or `open` while the disposition is still undecided. The ledger lives in the latest `VERIFY` comment of the ticket and is rewritten each round; the resolution comment links to it rather than duplicating it. Then the findings grouped by severity with file:line and the fix, as before, so the Verify step branches on the tag without parsing prose. A `status: clean` report whose counts line has Critical above zero must carry a ledger line closing each Critical (`fixed`, `deferred` or `accepted`); otherwise the report-status check fails it closed to `issues_found`.
24
24
 
25
25
  **Converge on the blockers with a bounded budget and an explicit boundary.** A **Critical** finding is *blocking*: it must be fixed before Resolve. Fixing one can introduce or reveal another, so the review is a **loop, not a single pass**: fix every Critical, then re-review the recomputed **Ticket diff** set, up to `config.code_review.rounds` (default 2) fix-and-re-review rounds. Stop when no Critical remains, then Resolve, giving each remaining Warning/Info its disposition in the ledger. **If the rounds are exhausted with Critical findings still open**, never resolve silently and never silently keep going: surface an explicit HITL checkpoint in the chat, **name each surviving blocker**, and put the choice to the user, (a) **run another 2 review rounds** on the remaining blockers (the point of this ask), or (b) **proceed with the blockers recorded** (each surviving Critical is recorded in the ledger as `deferred` or `accepted`), or (c) **run until it converges** (keep the fix + re-review loop going with no round cap, stopping only when no Critical remains; as a guard against an endless loop, if a round makes no progress, i.e. the same Critical findings come back unfixed, stop and bring the choice back to this checkpoint, naming them). **Wait for their pick; never auto-advance in either direction over open blocking findings.** (This mirrors **Cross-AI plan review**'s boundary ask: see `plan-review.md` step 4.)
26
26
 
@@ -20,7 +20,7 @@ Run it in the `build`/`bug` **Verify** step, after the tests / the plan's criter
20
20
 
21
21
  **Golden rule:** a claim that was **not evaluated** never becomes `met` nor `gaps_found`; it becomes `inconclusive`. `gaps_found` keeps its exact meaning, "evaluated, and the code does not satisfy the claim", and never absorbs a check that simply could not run.
22
22
 
23
- **Mixed states.** With claims falling in more than one class, emit **one** leading `status:` tag by precedence **`gaps_found` > `inconclusive` > `met`**, and in the body list every claim with its own class (and its one `reason:` where inconclusive). The engine clears the gaps first, autonomously (the Execute/Fix loop, AFK-friendly); the `inconclusive` claims resurface on re-verify and escalate to the human only when nothing else remains, so the cycle always converges.
23
+ **Mixed states.** With claims falling in more than one class, emit **one** leading `status:` tag by precedence **`gaps_found` > `inconclusive` > `met`**, and in the body list every claim with its own class (and its one `reason:` where inconclusive). The engine clears the gaps first, autonomously (the Execute/Fix loop, AFK-friendly); the `inconclusive` claims resurface on re-verify and escalate to the human only when nothing else remains, so the cycle always converges. Every claim listed in the body is one line carrying its class as `class: met | unmet | partial | inconclusive` (an inconclusive one also its one `reason:`), and a `met` report lists no `unmet`, `partial` or `inconclusive` claim: the report-status check raises a leading tag that contradicts the listed classes to the highest class by the precedence above (fail closed).
24
24
 
25
25
  **Routing.** On `gaps_found` the engine loops back to Execute/Fix for the gap, or runs the HITL checkpoint, before Resolve; it never resolves over an unmet goal silently. When the **leading** tag is `inconclusive` (every gap already cleared, so no `gaps_found` outranks it), the goal is **unevaluated, not satisfied**: it is a **blocking gate too** and is never auto-resolved over. (While `gaps_found` still leads, the Fix loop runs first per Mixed states above; the inconclusive claims only reach this checkpoint once no gap remains.) Because nothing changes between runs of a read-only check, there is **no auto-retry**; go straight to the HITL checkpoint, framed per reason-class (reusing the shared checkpoint mechanics in `code-review.md`: name each surviving blocker, put the choice to the user, never auto-advance):
26
26
  - `unrunnable-check` / `unreadable-evidence`: name the failed command / unreadable file, then offer (i) fix the environment and re-verify, or (ii) resolve accepting the claim as unverified (recorded in the `VERIFY` comment, the annotate-in-`VERIFY` path code review uses).
@@ -8,8 +8,8 @@ The ticket engines call these by name. Each technique's full protocol lives in i
8
8
 
9
9
  **Malformed review report (every engine-agent report).** The leading `status:` line, and the fields each report must carry, are machine-checked before the engine branches on them. This MUST run on every Code review and Goal-backward verification report and on every plan, executor, fix, debug, research and codebase-map report, whether it came from the subagent, an inline pass, or the lean inline pass; the report-status checker is `trailhead-report-status.js`, one JSON line `{"action","verdict","problem","report","fields","body"}` with `action` one of `accept` / `normalise` / `reask`. The return contract of each family (statuses, required fields, invariants) is stated once in the agents' `Return contract` blocks; the checker enforces it.
10
10
 
11
- 1. **Check.** Per report run `mktemp -d` once (a private 0700 random directory); write the raw report to `<that dir>/report.md` with the Write tool at the literal printed path; run `node <templates>/trailhead-report-status.js --technique <verify|code-review|plan|executor|fix|debug|research|codebase-map> --file <that dir>/report.md`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/` (skipped when unset), `~/.claude/trailhead/templates/` and `~/.codex/skills/trailhead/templates/` that contains it; then `rm -rf <that dir>` with the literal absolute path. Cleanup runs after every path: accept, normalise, each re-ask check, a second failure, and a checker failure. Never a fixed or predictable path, never reused. The checker also reads stdin, but this protocol does not use it (a heredoc would put the report into the shell-scanned command and needs a delimiter the report cannot contain).
12
- 2. **Accept or normalise.** The checker-returned `report` replaces the raw report where the technique places it (the layout below); the engine branches only on the returned `verdict`, and reads the returned `fields` (and, for a `ready` plan, `body`) instead of re-deriving them from the prose of the report.
11
+ 1. **Check.** Per report run `mktemp -d` once (a private 0700 random directory); write the raw report to `<that dir>/report.md` with the Write tool at the literal printed path; run `node <templates>/trailhead-report-status.js --technique <verify|code-review|plan|executor|fix|debug|research|codebase-map> --file <that dir>/report.md`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/` (skipped when unset), `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/` and `~/.codex/skills/trailhead/templates/` that contains it; then `rm -rf <that dir>` with the literal absolute path. Cleanup runs after every path: accept, normalise, each re-ask check, a second failure, and a checker failure. Never a fixed or predictable path, never reused. The checker also reads stdin, but this protocol does not use it (a heredoc would put the report into the shell-scanned command and needs a delimiter the report cannot contain).
12
+ 2. **Accept or normalise.** A review verdict contradicted by its own ledger or claim classes is normalised to the stricter verdict. The checker-returned `report` replaces the raw report where the technique places it (the layout below); the engine branches only on the returned `verdict`, and reads the returned `fields` (and, for a `ready` plan, `body`) instead of re-deriving them from the prose of the report.
13
13
  3. **Reask.** Ask once: continue the same agent where the host allows it, else re-dispatch the same `trailhead-<technique>` agent on the same model override with the malformed report quoted, using this corrective prompt verbatim (placeholders filled): "Your report could not be accepted: `<problem>` (missing: no `status:` line; unknown-token: `<found>` is not an allowed token; conflicting: more than one `status:` line, so quote or reword any status text in the body, which must not start a line with `status:` outside a code fence or blockquote; counts: the counts line is missing or repeated; fields: a required field is missing or an invariant is broken). Return the complete report again with its first line exactly `status: <token>`, where `<token>` is one of `<allowed tokens>`, and no other `status:` line<for code-review: , and its second line exactly `critical: <n> · warning: <n> · info: <n>`><for fields: , with each of `<required fields>` as a top-level `<label>:` line and `<the broken invariant>` holding, with nothing after the last field>. Do not post it anywhere." For code-review the prompt always restates both required lines; for `fields` it names the missing or empty fields and the invariant that broke.
14
14
  4. **Re-check the reply** the same way. Still a reask: no verdict, nothing posted for that round, no third ask, no inference from the body. Surface a HITL checkpoint in the chat naming the technique and the problem; Resolve stays blocked, and so does any step that needed the report; for a review the previous `VERIFY` comment stays latest.
15
15
  5. **No checker.** If the checker is missing, fails with exit 2, or prints non-JSON, apply the same rules by hand: the prose above stays the spec. Never guess a verdict.
@@ -36,7 +36,7 @@ Read **`references/inbox.md`** and follow it. It triages issues opened by others
36
36
 
37
37
  ## Standalone grill: `grill [topic|ticket]`
38
38
 
39
- Run a standalone **Grilling** (plus **Domain vocabulary**) session on a decision or topic, or on a named ticket, without committing to the full work cycle. Read the two techniques and follow them: `../_shared/techniques/grilling.md` and `../_shared/techniques/domain-vocabulary.md` (these are multi-cluster techniques, so their bodies live in `_shared/`). Before the final confirmation of the choices, run the **Holistic review before the final confirmation** defined in that technique. Record the outcome where it belongs: a ticket's resolution, the map's `Decisions so far`, or a fresh `decision` ticket. **When the session's decision unblocks or spawns follow-on tickets** (builds graduating from a resolved blocker, or siblings the decision implies), opening them **defers to the shared "Map or whiteboard" rule** (`../trailhead-capture/references/capture.md`): when one or more maps are open, ask the user which destination (a thematically eligible map vs the whiteboard), never silently picking the whiteboard and never treating "no coherent map" as a reason to auto-drop them there. Only with **no** open map does the whiteboard follow without asking.
39
+ Run a standalone **Grilling** (plus **Domain vocabulary**) session on a decision or topic, or on a named ticket, without committing to the full work cycle. Read the two techniques and follow them: `../_shared/techniques/grilling.md` and `../_shared/techniques/domain-vocabulary.md` (these are multi-cluster techniques, so their bodies live in `_shared/`). Before the final confirmation of the choices, run the **Holistic review before the final confirmation** defined in that technique. Record the outcome where it belongs: a ticket's resolution, the map's `Decisions so far`, or a fresh `decision` ticket. **When the session resolves a `decision` ticket**, its close follows the decision engine's **Carry it into build** step (`../trailhead-work/references/ticket-engines.md`): the build split goes into the final confirmation, the build tickets are created, and the resolution comment carries the `Builds:` line before `gh issue close`. **When the session's decision unblocks or spawns follow-on tickets** (builds graduating from a resolved blocker, or siblings the decision implies), opening them **defers to the shared "Map or whiteboard" rule** (`../trailhead-capture/references/capture.md`): when one or more maps are open, ask the user which destination (a thematically eligible map vs the whiteboard), never silently picking the whiteboard and never treating "no coherent map" as a reason to auto-drop them there. Only with **no** open map does the whiteboard follow without asking.
40
40
 
41
41
  ## First-time config offer (end of chart / adopt)
42
42
 
@@ -9,7 +9,7 @@ The user invokes with a loose idea.
9
9
  1. **Name the destination.** Run the **Grilling** + **Domain vocabulary** techniques to pin down what this map tends toward. The destination fixes the scope, so it's settled first. Default: a working artifact.
10
10
  2. **Map the frontier.** Grill again, **breadth-first**: **enumerate every functional area the destination implies** and **capture each one (headed for a fog entry under *Not yet specified* at minimum) before deep-diving any single area**. Fan out across the whole space rather than deep on one thread, surfacing the open decisions and the first steps takeable now. **The deliverable is the coverage the map will carry, not the grilling transcript: every area the destination implies must land on the map at step 3, as fog or as a ticket, so nothing is silently missing.** **If no fog surfaces** (the way is already clear, the whole thing fits one session) you don't need a map: stop and ask how to proceed. **Harvest parked work:** from **earlier maps on this repo**, gather the artifacts their *Out of scope* `→ future map` breadcrumbs point at (**parked seeds/todos**, see **Out of scope**) **and the ideas** left in their *Not yet specified* fog, then **ask the user which belong in *this* map**; pull the chosen seeds/todos in as tickets and the chosen ideas in as fog (or as tickets, if now sharp enough), and leave (or update the destination on) the rest.
11
11
  3. **Create the map** (`trailhead:map`), after the **Holistic review before the final confirmation** of the Grilling technique has run on the step 1 destination and the step 2 choices together: Destination and Notes filled in, Decisions-so-far empty, the fog sketched into *Not yet specified*. **Every functional area enumerated in step 2 must appear here, as a fog entry or a ticket, before moving on: the map is the coverage deliverable.** Run the **first-use repo setup** (see **Substrate** → `../../_shared/substrate-commands.md`) now: labels **and** the label-guard check/install. Once the map issue has its number `<n>`, **create its per-map label `trailhead:map-<n>`** (every ticket of this map carries it and is a native sub-issue of the map; the map issue itself is found by its number, so it does **not** carry the label; see **Multiple maps on one repo**). **If this repo already has another open `trailhead:map`**, tell the user the new map runs concurrently (its own scoped frontier) and set it as the active map (`.trailhead/active-map`). Then set up **conventions**: if no `trailhead:conventions` issue exists (singleton lookup: see **Singletons** in `../../_shared/substrate.md`), run a **brief conventions brainstorming** (breadth-first, in the spirit of grilling but short: a handful of questions, not a full session) to surface the project's way of working: `git:` `main`|`pr`, `release:` `command`|`auto`, and `isolation:` `auto`|`none`|`worktree`|`clone` (default **`auto`**, which decides per ticket at work-start by asking *"will more than one ticket be worked at once on this machine this session?"* and picking `worktree` for a plain repo or `clone` for a submodule superrepo; a project can force `none`/`worktree`/`clone` explicitly), **plus** the narrative a new contributor would need: release/deploy specifics (environments, manual steps, OTA vs full build, versioning), tooling that must be driven a certain way (a specific MCP/CLI, e.g. a Lovable project piloted via its MCP), code & style rules, testing/UAT norms. Distil into the issue (header + prose); link it from the map's Notes. Don't invent: leave out or ask what you can't determine. If one already exists, just link it (reuse across maps). Finally, **ensure the repo's `trailhead:dashboard` index issue exists** (see **The dashboard**): create and pin it if missing (the fixed 3rd pinned slot, alongside codebase + conventions), then **write it to list this map** (charting a map is a structural event); if it already exists, refresh it.
12
- 4. **Create the tickets you can specify now** as child issues (each with `trailhead:ticket` + its type + this map's `trailhead:map-<n>` label, **plus `trailhead:blocked` when the mapped dependency graph already shows it has a blocker**, and set as a **native sub-issue** of the map, see **Child→map link** under Substrate), then wire the blocking in a **second pass** (issues need ids before they can reference each other): for each ticket **read its Question and wire a blocker for every still-open ticket whose output it consumes** (don't stop at the first/obvious one) by wiring its `## Blocked by` line + native dependency together (moves 1-2 of the three-move wiring; the `trailhead:blocked` label, move 3, already rode in at creation, see **Substrate**). **Set the blocked label at creation, not via a post-creation `--add-label` pass:** a bulk `--add-label` burst spawns one repo-side label-guard run per ticket and reddens the Actions history with sheddable no-op runs; riding the label in the ticket's `issues.opened` event avoids that entirely. Wiring sorts them into frontier (unblocked) and blocked; the rest stays fog.
12
+ 4. **Create the tickets you can specify now** (the **Ticket or fog** test below) as child issues (each with `trailhead:ticket` + its type + this map's `trailhead:map-<n>` label, **plus `trailhead:blocked` when the mapped dependency graph already shows it has a blocker**, and set as a **native sub-issue** of the map, see **Child→map link** under Substrate), then wire the blocking in a **second pass** (issues need ids before they can reference each other): for each ticket **read its Question and wire a blocker for every still-open ticket whose output it consumes** (don't stop at the first/obvious one) by wiring its `## Blocked by` line + native dependency together (moves 1-2 of the three-move wiring; the `trailhead:blocked` label, move 3, already rode in at creation, see **Substrate**). **Set the blocked label at creation, not via a post-creation `--add-label` pass:** a bulk `--add-label` burst spawns one repo-side label-guard run per ticket and reddens the Actions history with sheddable no-op runs; riding the label in the ticket's `issues.opened` event avoids that entirely. Wiring sorts them into frontier (unblocked) and blocked; the rest stays fog. **Ticket or fog.** Run it on every area from step 2: "specify now" means you can write the area's `## Question` now, sized to one session, not that it can be answered now. An area whose Question you can write is a ticket, created already blocked (`trailhead:blocked` plus its wiring) on the tickets whose answers it needs: being blocked is not a reason to leave it in the fog, because a blocked ticket graduates onto the frontier by itself when its blockers close, while a fog entry waits for someone to dissolve it. An area stays fog only when you **cannot phrase** its Question yet (you don't know what to ask: "Monetization" before the audience is known), or when an open decision could **reshape** the question itself (not just its answer), in which case the fog entry names that decision so it is re-examined when that decision closes. An area too big for one session is several tickets, or one ticket plus fog for the rest. Volume is not a reason to keep an area in the fog: the only brake is GitHub's 100 sub-issue cap per map, and near it, say so and keep the furthest-out areas as fog.
13
13
  5. **Fire the research subagents.** For each `research` ticket created, run the **Research** technique in parallel; each agent returns its report, and the orchestrator posts it on the ticket as a `RESEARCH` comment (no branch: the report is the artifact; see the technique for the `found` / `inconclusive` / `needs_input` branches).
14
14
  6. **Offer config, then stop.** Charting is one session's work; it hand-resolves nothing. Before ending, make the **first-time config offer** (see **First-time config offer** in `../SKILL.md`): the project now has a fresh map and no `.trailhead/config.json`, so put the **two-way choice** to the user, **accept as-is** (writes `{}`) or **run `/trailhead:config`** for the guided menu, and let them pick. Name both paths; don't pre-fill the config keys and ask for an inline edit, and don't interview them key-by-key here (that's `/trailhead:config`'s job). Once the file exists it's never offered again. **No `trailhead:codebase` issue is created here**: greenfield has no code to map yet. It's created later, once the repo has substantial code (see Mode 2 step 1 and the **Codebase** row under Substrate), so at chart two of the three permanent pins (the dashboard + conventions) are filled and the codebase slot fills in later. Maps are never pinned.
15
15
 
@@ -44,9 +44,9 @@ Run in this order, so the cost guard counts only what the cheap passes leave ove
44
44
  - **Shallow clone** (`git rev-parse --is-shallow-repository` prints `true`): say so and send every `build` / `bug` to the agent. Such tickets are also **ineligible for evolved**: blame at the shallow boundary inflates the tracked set.
45
45
  - **Dirty working tree**: the audit judges HEAD and ignores uncommitted work, and the step 5 agent reads code at HEAD, never the working tree. If `git status --porcelain` lists any of a ticket's touched files (modified or untracked), the report notes them (the user may want to commit first). That changes nothing else: no agent-bound effect, no evolved ineligibility. A clean tree needs nothing.
46
46
 
47
- **Run it with the shipped script.** The fast path above is implemented by `node <templates>/trailhead-audit-walk.js`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/` (skip it when `CLAUDE_PLUGIN_ROOT` is unset), `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains it. `index --repo <root>` prints the trailer index as JSON (save it and pass it back with `--index <file>` so the whole audit scans once; an index taken at another HEAD is rejected with exit 2). `fastpath --repo <root> [--index <file>] <n>...` prints `{shallow, tickets:[{ticket, class, reason, evolvedEligible, last, commits, files, dirtyFiles}]}`: `class` is `unchanged` or `changed` (agent-bound), `reason` is null when unchanged, else one of `no-refs`, `non-linear`, `shallow`, `guard`, `touching-hunk`, `deleted-or-renamed`, `recreated`, `non-textual`, `git-error`, `evolvedEligible` says whether step 6 may class the ticket evolved, and `dirtyFiles` feeds the report's dirty-file note. The prose stays the spec: if the script is missing or errors, follow the steps above by hand.
47
+ **Run it with the shipped script.** The fast path above is implemented by `node <templates>/trailhead-audit-walk.js`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/` (skip it when `CLAUDE_PLUGIN_ROOT` is unset), `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains it. `index --repo <root>` prints the trailer index as JSON (save it and pass it back with `--index <file>` so the whole audit scans once; an index taken at another HEAD is rejected with exit 2). `fastpath --repo <root> [--index <file>] <n>...` prints `{shallow, tickets:[{ticket, class, reason, evolvedEligible, last, commits, files, dirtyFiles}]}`: `class` is `unchanged` or `changed` (agent-bound), `reason` is null when unchanged, else one of `no-refs`, `non-linear`, `shallow`, `guard`, `touching-hunk`, `deleted-or-renamed`, `recreated`, `non-textual`, `git-error`, `evolvedEligible` says whether step 6 may class the ticket evolved, and `dirtyFiles` feeds the report's dirty-file note. The prose stays the spec: if the script is missing or errors, follow the steps above by hand.
48
48
 
49
- **Light check** for `decision` / `research` / `task` / `prototype` (inline, no agent): the recorded answer or outcome exists and answers the Question: a resolution comment, or its map `Decisions so far` line, or the linked artifact or stated resulting facts. A missing one is **not implemented**; an unclear one is unverifiable. There is no code check.
49
+ **Light check** for `decision` / `research` / `task` / `prototype` (inline, no agent): the recorded answer or outcome exists and answers the Question: a resolution comment, or its map `Decisions so far` line, or the linked artifact or stated resulting facts. A missing one is **not implemented**; an unclear one is unverifiable. There is no code check. A closed `decision` also gets a **build check**: its resolution comment carries a `Builds:` line (the decision engine's **Carry it into build** step), or, for a decision closed before that line existed, at least one ticket (open or closed, any type but `decision`) links it back (its body names the decision's number or URL). A `Builds: none (<why>)` line passes. A decision with neither is flagged **no build**: its answer was recorded but no ticket carries the work. The flag sits outside the class sum (the decision itself may still be implemented by the light check) and changes nothing until a proposal is accepted.
50
50
 
51
51
  **Cost guard**: count the **agent-bound set** only (never the fast-path or light-check tickets). When it **exceeds 20** tickets, show the count and ask whether to proceed, narrow to one map, or cancel. At 20 or fewer, go straight on.
52
52
 
@@ -102,7 +102,7 @@ Over 60 open tickets in scope, run stage (a) per map plus one cross-map pass ove
102
102
 
103
103
  ## 8. Report
104
104
 
105
- One consolidated report: counts per class, which must sum to the audited set (each ticket is counted **once by its primary class**, the step 6 precedence, and plain unverifiable only when no unmet claim exists): implemented (by the agent or by the light check, giving the light-check share), **unchanged**, **evolved**, implemented wrong, not implemented, unverifiable, then each evolved ticket by name and number with the evolving ticket(s), then each other non-implemented or unverifiable ticket by name and number with a one-line reason, the tickets **also unverifiable in part** listed separately, **outside the sum**, then the duplicate and groupable findings, and any dirty-file note or shallow-clone note from step 4. Nothing has changed yet.
105
+ One consolidated report: counts per class, which must sum to the audited set (each ticket is counted **once by its primary class**, the step 6 precedence, and plain unverifiable only when no unmet claim exists): implemented (by the agent or by the light check, giving the light-check share), **unchanged**, **evolved**, implemented wrong, not implemented, unverifiable, then each evolved ticket by name and number with the evolving ticket(s), then each other non-implemented or unverifiable ticket by name and number with a one-line reason, the tickets **also unverifiable in part** listed separately, **outside the sum**, then the decisions flagged **no build** (step 4), also outside the sum, each by name and number, then the duplicate and groupable findings, and any dirty-file note or shallow-clone note from step 4. Nothing has changed yet.
106
106
 
107
107
  ## 9. Proposals, each on an explicit yes
108
108
 
@@ -111,6 +111,7 @@ One proposal per finding, shown one at a time, applied only on an explicit yes.
111
111
  - **Implemented wrong** → a new `bug` carrying `Regression of: <ticket>`, via the capture cluster's `bug --of <n>` (a bug in already-closed work is always a new ticket; see the capture cluster's `bug` engine in `../../trailhead-capture/`). Before proposing it for ticket <n>, search exhaustively (`is:issue`, open, or closed after <n>'s `closedAt`) for an existing ticket whose body carries `Regression of:` pointing at <n> (match the number or its URL). If one exists, report <n> as already tracked, naming that ticket, instead of proposing a new bug; this prevents duplicates when a rewound marker re-audits tickets.
112
112
  - **Not implemented** → `gh issue reopen <n>` plus a comment stating the gap (the premature-close branch of new-bug-versus-reopen).
113
113
  - **Evolved** → no proposal: listed in the report only, because a later ticket changed the code on purpose.
114
+ - **No build** → open the missing build tickets, per the decision engine's **Carry it into build** step (`../../trailhead-work/references/ticket-engines.md`): read the decision's resolution, propose the split (titles, one line of scope each, blockers, any existing open ticket that already covers a piece), and on a yes create them create-then-wire on the decision's map (the whiteboard for a whiteboard decision), each with a `Decided in: <name+link>` line, then post a comment on the decision carrying the `Builds:` line. If the human says the decision builds nothing, post `Builds: none (<why>)` with their reason instead. The ticket split is human-owned: ask it as plain yes/no, never with the delegate option.
114
115
  - **Duplicate** → first **carry the duplicate's wiring onto the kept ticket**, in all three views (the `## Blocked by` line, the native `blocked_by` edge, the `trailhead:blocked` label): each open dependent of the duplicate is re-pointed to the kept ticket unless already wired there, and the union of blockers is built by adding each open blocker of the duplicate that the kept ticket lacks, both **excluding the pair itself** so the kept ticket is never re-pointed onto itself or made to block on itself. Then close the newer one natively as a duplicate of the kept one, `gh issue close <n> --duplicate-of <k>` (this sets the `DUPLICATE` state reason the window filter reads), plus a comment naming the kept ticket. If the duplicate was one of the kept ticket's blockers, that edge is removed (the native edge auto-reflects the close; also drop it from the `## Blocked by` line) instead of transferred, and the kept ticket's `trailhead:blocked` label is re-evaluated only after the duplicate's blockers have been added to it: drop `trailhead:blocked` if that was its last open blocker. Its native sub-issue edge stays: the eager drop is reserved to superseded and out-of-scope closes, and `/trailhead:prune` reclaims closed edges under cap pressure.
115
116
  - **Groupable** → **supersede-and-merge**, the reverse of the split in `../../_shared/teamwork.md`. Open one merged ticket on the same map or whiteboard (create-then-wire). Its blockers are the union of the originals' open blockers **minus the group's own members**, wired in all three views (the `## Blocked by` line, the native `blocked_by` edge, and the `trailhead:blocked` label at creation). Every dependent of an original **outside the group** is re-pointed onto the merged ticket in all three views (body line edited, native edge added to the merged ticket; the original's edge reflects its close on its own). Intra-group edges are dropped, never re-pointed, so the merged ticket never blocks on, or depends on, itself. The merged ticket's body carries a `Merged from: <name+link>, ...` provenance line (mirroring split's `Split from:`). Then close each original as `trailhead:superseded` with the eager edge drop and a closing comment linking the merged ticket.
116
117