@marcomigozzi/trailhead 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -44,6 +44,32 @@
44
44
  // are shared with trailhead-search-guard.js via lib/shell-scan.js, so the two
45
45
  // guards can never drift into two divergent tokenizers again.
46
46
  //
47
+ // #214 fix: a secret read inside a command substitution was missed when the
48
+ // substitution sat in double quotes (`echo "$(cat .env)"`, `x="$(cat .env)"`,
49
+ // `echo "$(true; cat .env)"`): the quoted token hid it, and the splitter cut
50
+ // inside the `$(..)`. Each outermost `$(..)` / `<(..)` / `>(..)` body (from
51
+ // substitutionBodies in lib/shell-scan.js) is now also scanned as a command,
52
+ // off a depth- and size-capped work-list.
53
+ //
54
+ // #216 fix: the same miss for backtick substitutions (`echo "`cat .env`"`,
55
+ // `x="`cat .env`"`, `echo "${x:-`cat .env`}"`). scanLine now tracks a backtick
56
+ // span and substitutionBodies returns its body (unescaped as the shell would
57
+ // run it) alongside the `$(..)` ones, so the work-list scans it too.
58
+ //
59
+ // #218 fix: the same miss for a substitution in the body of an UNQUOTED heredoc
60
+ // (`cat <<EOF` with `$(cat .env)` or a backtick inside): the shell runs it, but
61
+ // heredoc bodies are data to the splitter and were never lexed. Each unquoted
62
+ // body is now lexed as the shell reads one (heredocSubstitutionBodies: quotes
63
+ // are literal there) and its outermost substitution bodies join the work-list.
64
+ // A quoted delimiter (`<<'EOF'`) and an escaped `\$(..)` stay literal.
65
+ //
66
+ // #223 fix: a heredoc body fed to a shell interpreter on stdin (`bash <<'EOF'`,
67
+ // `sh <<EOF`, `cat <<EOF | bash`) is a script the shell runs, whatever the
68
+ // delimiter quoting, but bodies were only ever data. Each statement is now paired
69
+ // with its own bodies by position (as trailhead-secret-guard.js does); when a
70
+ // statement of its pipeline invokes a shell from a closed set, those bodies join
71
+ // the work-list as commands. No wrappers (sudo, exec, nohup): out of scope.
72
+ //
47
73
  // #180 fix: `docker`/`podman` `--env-file .env` (or `--env-file=.env`) was a
48
74
  // false positive: the container RUNTIME reads that file, not the agent. Only
49
75
  // that flag's value is exempted, and only for docker/podman verbs; see
@@ -60,6 +86,14 @@
60
86
  // a link prepared under a non-secret name onto `.env`
61
87
  // (`ln -s .env.example l && mv l .env`).
62
88
  //
89
+ // #227: a construct the guard cannot interpret with certainty (eval, source / `.`, a
90
+ // bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted gh api body
91
+ // key, a backslash-newline inside a word) is not emulated. As a FINAL gate, after the
92
+ // precise scan, one that sits next to a secret-looking piece (`.env`, `.e""nv`, ...)
93
+ // denies with SECRET_READ_UNMODELLED_CONSTRUCT and asks for the simple form; with no
94
+ // such piece (`. venv/bin/activate && make`) it passes. The closed list is
95
+ // lib/unmodelled.js.
96
+ //
63
97
  // Deliberately best-effort on the Bash leg, like the sibling guards: not a
64
98
  // full shell parser. It does not care about `cd` shape at all (that is
65
99
  // trailhead-search-guard.js's job) — it only asks "does any token here
@@ -76,7 +110,12 @@
76
110
  const {
77
111
  basename,
78
112
  stripQuotes,
79
- splitStatements,
113
+ splitStatementsWithSeparators,
114
+ countHeredocs,
115
+ skipEnvPrefix,
116
+ substitutionBodies,
117
+ heredocBodies,
118
+ heredocSubstitutionBodies,
80
119
  tokenize,
81
120
  SEARCH_VERB_CONFIG,
82
121
  scanArgsSkippingPattern,
@@ -391,6 +430,47 @@ function scanStatementForSecretPath(stmt) {
391
430
  return scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
392
431
  }
393
432
 
433
+ // #214: substitution bodies are scanned off an explicit work-list (no
434
+ // recursion, so no stack overflow) under a cap on nesting depth and on the
435
+ // total bytes of bodies scanned. Past the cap the remaining text is checked
436
+ // crudely instead (see crudeSecretToken): the guard fails toward blocking.
437
+ const MAX_SUB_DEPTH = 32;
438
+ const MAX_SUB_BYTES = 64 * 1024;
439
+ const CRUDE_SPLIT_RE = /[\s"'`()$;|&<>{}=]+/;
440
+
441
+ // Fallback when the cap is hit: split the unscanned text on whitespace, quotes,
442
+ // parens and operators, and return the first piece that is a secret path.
443
+ function crudeSecretToken(text) {
444
+ for (const piece of text.split(CRUDE_SPLIT_RE)) {
445
+ if (piece && isSecretPathArg(piece)) return piece;
446
+ }
447
+ return null;
448
+ }
449
+
450
+ // #223: shells that read a script from stdin (closed set, no wrappers).
451
+ const STDIN_SCRIPT_INTERPRETERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
452
+ // Reserved words that may precede the command word (no grouping, no wrappers).
453
+ const LEADING_KEYWORDS = new Set(['then', 'do', 'else', 'time', '!']);
454
+ // `env` options whose value is the next token (separate form, no `=`).
455
+ const ENV_VALUE_OPTIONS = new Set(['-u', '-C', '--unset', '--chdir']);
456
+
457
+ // True when the statement runs one of those shells: after leading NAME=value
458
+ // assignments, an optional `env` (with its NAME=value and -option words), the
459
+ // command word's basename is in the set. Flags after it do not matter.
460
+ function invokesInterpreter(stmt) {
461
+ const tokens = tokenize(stmt);
462
+ let i = 0;
463
+ while (i < tokens.length && LEADING_KEYWORDS.has(tokens[i])) i++;
464
+ i += skipEnvPrefix(tokens.slice(i));
465
+ if (i < tokens.length && basename(stripQuotes(tokens[i])) === 'env') {
466
+ i++;
467
+ while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || tokens[i].startsWith('-'))) {
468
+ i += ENV_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
469
+ }
470
+ }
471
+ return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(stripQuotes(tokens[i])));
472
+ }
473
+
394
474
  // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
395
475
  // `||`, and newlines all split, even with no surrounding whitespace, e.g.
396
476
  // `cat .env|grep KEY`) looking for a secret path used as a plain positional,
@@ -398,10 +478,80 @@ function scanStatementForSecretPath(stmt) {
398
478
  // (quotes/punctuation not necessarily stripped) or null. Best-effort: not a
399
479
  // full shell parser.
400
480
  function scanBashForSecretPath(cmd) {
401
- const statements = splitStatements(cmd);
402
- for (const stmt of statements) {
403
- const hit = scanStatementForSecretPath(stmt);
404
- if (hit) return hit;
481
+ const work = [{ text: String(cmd), depth: 0 }];
482
+ let bytes = 0;
483
+ for (let w = 0; w < work.length; w++) {
484
+ const { text, depth } = work[w];
485
+ if (depth > 0) bytes += text.length;
486
+ if (depth > MAX_SUB_DEPTH || bytes > MAX_SUB_BYTES) {
487
+ const hit = crudeSecretToken(text);
488
+ if (hit) return hit;
489
+ continue;
490
+ }
491
+ const bodies = heredocBodies(text);
492
+ const entries = splitStatementsWithSeparators(text);
493
+ let next = 0;
494
+ const pushed = new Set(); // bodies already scanned whole as commands
495
+ let pipe = []; // consecutive statements joined by `|`
496
+ const flush = () => {
497
+ if (pipe.some((p) => invokesInterpreter(p.text))) {
498
+ for (const doc of pipe.flatMap((p) => p.docs)) {
499
+ work.push({ text: doc.body, depth: depth + 1 });
500
+ pushed.add(doc);
501
+ }
502
+ }
503
+ pipe = [];
504
+ };
505
+ for (const entry of entries) {
506
+ const hit = scanStatementForSecretPath(entry.text);
507
+ if (hit) return hit;
508
+ const docs = bodies.slice(next, next + countHeredocs(entry));
509
+ next += docs.length;
510
+ pipe.push({ text: entry.text, docs });
511
+ if (entry.sep !== '|') flush();
512
+ }
513
+ flush();
514
+ // #214: a statement cut inside `"$(..)"` loses the closing context, and a
515
+ // double-quoted token hides its body from the per-token walk; scan each
516
+ // outermost substitution body as a command of its own (nested ones next).
517
+ for (const body of substitutionBodies(text)) work.push({ text: body, depth: depth + 1 });
518
+ // #218: an unquoted heredoc body is data to the splitter, but the shell runs
519
+ // the substitutions in it; a quoted delimiter makes the body literal.
520
+ // (#223: a body already pushed whole as a command needs no second push.)
521
+ for (const doc of bodies) {
522
+ if (doc.quoted || pushed.has(doc)) continue;
523
+ for (const body of heredocSubstitutionBodies(doc.body)) work.push({ text: body, depth: depth + 1 });
524
+ }
525
+ }
526
+ return null;
527
+ }
528
+
529
+ // --- unmodelled constructs (#227) ----------------------------------------------
530
+
531
+ // Does a text hold a secret-looking piece once live backslash-newlines are joined and
532
+ // quote characters dropped (`.e""nv` is `.env` to the shell)? Crude by design: the
533
+ // precise scan above is exact and runs first, this only has to catch what it cannot see.
534
+ function crudeSecretPiece(text) {
535
+ return crudeSecretToken(String(text).replace(/\\\n/g, '').replace(/["'`]/g, ''));
536
+ }
537
+
538
+ // A construct the guard cannot interpret (eval, source / `.`, a bash 5.3 `${ cmd; }`,
539
+ // printf -v, a function definition, an alias, a quoted gh api body key, a backslash-newline
540
+ // inside a word) next to a secret-looking piece: { hit, piece } or null. The piece only has
541
+ // to sit in the construct's own statement or in the code it hides, which is not proof of a
542
+ // read (`printf -v x %s .env` blocks too): the uncertain shape is blocked and the simple
543
+ // form asked for, not emulated.
544
+ function unmodelledSecretPiece(cmd) {
545
+ let lib; // loaded lazily: a missing or broken lib only switches this gate off
546
+ try {
547
+ lib = require('./lib/unmodelled.js');
548
+ } catch {
549
+ return null;
550
+ }
551
+ for (const hit of lib.findUnmodelled(cmd)) {
552
+ if (hit.id === 'api-field') continue; // a body-write concern (body-guard), not a read
553
+ const piece = crudeSecretPiece(hit.stmt) || crudeSecretPiece(hit.payload);
554
+ if (piece) return { hit, piece };
405
555
  }
406
556
  return null;
407
557
  }
@@ -420,12 +570,27 @@ function detectSecretRead(toolName, toolInput) {
420
570
  if (toolName === 'Bash') {
421
571
  const cmd = input.command || '';
422
572
  const hit = scanBashForSecretPath(cmd);
423
- return hit ? { path: hit } : null;
573
+ if (hit) return { path: hit };
574
+ const um = unmodelledSecretPiece(cmd); // a final gate: the precise scan keeps priority
575
+ return um ? { path: um.piece, unmodelled: um.hit } : null;
424
576
  }
425
577
  return null;
426
578
  }
427
579
 
580
+ function blockUnmodelled(hit) {
581
+ let redact; // the secret-guard's redactor; without it the message omits the sample
582
+ try {
583
+ ({ redact } = require('./trailhead-secret-guard.js'));
584
+ } catch {
585
+ redact = undefined;
586
+ }
587
+ const reason = require('./lib/unmodelled.js').unmodelledReason('secret-read guard', 'that it does not read a secret file (.env / .env.* / .secrets)', hit.unmodelled, redact);
588
+ process.stdout.write(JSON.stringify({ decision: 'block', code: 'SECRET_READ_UNMODELLED_CONSTRUCT', reason }));
589
+ process.exit(2);
590
+ }
591
+
428
592
  function block(hit) {
593
+ if (hit.unmodelled) blockUnmodelled(hit);
429
594
  const reason =
430
595
  `trailhead secret-read guard: blocked a read of secret file \`${hit.path}\` ` +
431
596
  '(.env / .env.* / .secrets). This replaces the Read() deny permission rules ' +
@@ -34,6 +34,18 @@ ok('Bash grep -n KEY .env.production is denied', !!detectSecretRead('Bash', { co
34
34
  ok('Bash cat app/.env.local is denied', !!detectSecretRead('Bash', { command: 'cat app/.env.local' }));
35
35
  ok('Bash cat --file=.env is denied', !!detectSecretRead('Bash', { command: 'cat --file=.env' }));
36
36
 
37
+ // --- line continuation splits a secret path (#222) ---
38
+ ok('Bash cat \\<nl>.env is denied', !!detectSecretRead('Bash', { command: 'cat \\\n.env' }));
39
+ ok('Bash echo $(cat \\<nl>.env) is denied', !!detectSecretRead('Bash', { command: 'echo $(cat \\\n.env)' }));
40
+ ok('Bash cat .e\\<nl>nv is denied', !!detectSecretRead('Bash', { command: 'cat .e\\\nnv' }));
41
+ ok('Bash cat .e\\\\<nl>nv (escaped backslash) is allowed', !detectSecretRead('Bash', { command: 'cat .e\\\\\nnv' }));
42
+ ok('Bash continuation inside single quotes stays literal', !detectSecretRead('Bash', { command: "echo 'x\\\n.env'" }));
43
+ ok('Bash consecutive continuations cat \\<nl>\\<nl>.env are denied', !!detectSecretRead('Bash', { command: 'cat \\\n\\\n.env' }));
44
+ ok('Bash separator on the continuation line is denied', !!detectSecretRead('Bash', { command: 'echo a \\\n; cat .env' }));
45
+ ok('Bash continuation inside a backtick span is denied', !!detectSecretRead('Bash', { command: 'echo `cat \\\n.env`' }));
46
+ ok('Bash continuation inside "$(..)" is denied', !!detectSecretRead('Bash', { command: 'echo "$(cat .e\\\nnv)"' }));
47
+ ok('Bash trailing backslash on the last line is allowed', !detectSecretRead('Bash', { command: 'echo done \\' }));
48
+
37
49
  // --- ALLOW cases (no false positives) ---
38
50
  ok('Read src/app.ts is allowed', !detectSecretRead('Read', { file_path: 'src/app.ts' }));
39
51
  ok('Bash grep -n x pubspec.yaml is allowed', !detectSecretRead('Bash', { command: 'grep -n x pubspec.yaml' }));
@@ -46,6 +58,8 @@ ok('Read .environment (near-miss) is allowed', !detectSecretRead('Read', { file_
46
58
  // --- #135 follow-up: Finding 1 - glued shell metacharacters must not bypass detection ---
47
59
  ok('Bash cat .env|grep KEY is denied (glued pipe)', !!detectSecretRead('Bash', { command: 'cat .env|grep KEY' }));
48
60
  ok('Bash cat .env;echo done is denied (glued semicolon)', !!detectSecretRead('Bash', { command: 'cat .env;echo done' }));
61
+ ok('Bash cat .env after an arithmetic << on the previous line is denied (#212)',
62
+ !!detectSecretRead('Bash', { command: 'echo $((1<<3))\ncat .env' }));
49
63
  ok('Bash cat .env&&echo done is denied (glued &&)', !!detectSecretRead('Bash', { command: 'cat .env&&echo done' }));
50
64
  ok('Bash bash -c "cat .env" is denied (quoted sub-command)', !!detectSecretRead('Bash', { command: 'bash -c "cat .env"' }));
51
65
  ok('Bash eval "cat .env" is denied (quoted sub-command)', !!detectSecretRead('Bash', { command: 'eval "cat .env"' }));
@@ -180,13 +194,169 @@ denyCmd('cp .env.example .env -f');
180
194
  denyCmd('cp .env.example .env && cat .env');
181
195
  denyCmd('git check-ignore .env; cat .env');
182
196
 
197
+ // #214: a secret read inside a command substitution, even double-quoted
198
+ const hit214 = (c) => { const r = detectSecretRead('Bash', { command: c }); return !!r && r.path === '.env'; };
199
+ for (const c of ['echo "$(true; cat .env)"', 'echo "$(cat .env)"', 'x="$(cat .env)"', 'diff <(cat .env) x',
200
+ 'echo "$(echo "$(cat .env)")"', 'echo "$(\ncat .env\n)"']) {
201
+ ok(`Bash \`${c}\` is denied with path .env (#214)`, hit214(c));
202
+ }
203
+ allowCmd("echo '$(cat .env)'");
204
+ allowCmd('echo "$(echo hi)"');
205
+ allowCmd('echo "$((1+2))"');
206
+ allowCmd('git commit -m "document .env usage"');
207
+
208
+ // #214 review: heredoc inside a substitution, substitution inside arithmetic
209
+ for (const c of ['echo "$(cat <<E\nfoo\nE\n; cat .env)"', 'echo $(cat <<E\nfoo\nE\n; cat .env)',
210
+ 'echo "$(( $(cat .env) ))"', 'echo $(( $(cat .env) ))', 'echo "$[ $(cat .env) ]"']) {
211
+ ok(`Bash \`${c}\` is denied with path .env (#214 review)`, hit214(c));
212
+ }
213
+ allowCmd('echo "$((1+2))"');
214
+ allowCmd('echo $((a+(b)))');
215
+ allowCmd('echo "$(( $(echo 1) + 1 ))"');
216
+
217
+ // #216: a secret read inside a backtick substitution, even double-quoted
218
+ for (const c of ['echo "`cat .env`"', 'x="`cat .env`"', 'echo "${x:-`cat .env`}"', 'echo `cat .env`',
219
+ 'echo "$(echo `cat .env`)"', 'echo `echo $(cat .env)`', 'echo `echo \\`cat .env\\``']) {
220
+ ok(`Bash \`${c}\` is denied with path .env (#216)`, hit214(c));
221
+ }
222
+ for (const c of ['echo "`echo \'"\'`"; cat .env', 'echo `true\ncat .env\n`',
223
+ 'echo `echo \\`echo \\\\\\`cat .env\\\\\\`\\``']) {
224
+ ok(`Bash \`${c}\` is denied with path .env (#216 review)`, hit214(c));
225
+ }
226
+ { // \" inside a double-quoted backtick body: bash reads the quoted .env (the path keeps its quotes)
227
+ const r = detectSecretRead('Bash', { command: 'echo "`cat \\".env\\"`"' });
228
+ ok('Bash `echo "`cat \\".env\\"`"` is denied (#216 review)', !!r && /\.env/.test(r.path));
229
+ }
230
+ allowCmd('echo "run `ls` then `pwd`"');
231
+ allowCmd("echo '`cat .env`'");
232
+ allowCmd('echo "\\`cat .env\\`"');
233
+
234
+ // #218: a secret read inside the body of an UNQUOTED heredoc (the shell runs $(..) and backticks there)
235
+ for (const c of ['cat <<EOF\n$(cat .env)\nEOF', 'cat <<EOF\n`cat .env`\nEOF', 'cat <<-EOF\n\t$(cat .env)\n\tEOF',
236
+ "cat <<EOF\nit's $(cat .env)\nEOF", 'cat <<EOF\nsay "hi $(cat .env)\nEOF', 'cat <<EOF\n${x:-$(cat .env)}\nEOF',
237
+ 'cat <<EOF\nfirst line\nthen text $(cat .env) more\nEOF', 'x=$(cat <<EOF\n$(cat .env)\nEOF\n)',
238
+ 'cat <<A\n$(cat <<B\n)\nB\ncat .env)\nA', 'cat <<EOF\n$\\\n(cat .env)\nEOF', 'cat <<EOF\n$(cat .e\\\nnv)\nEOF',
239
+ 'cat <<EOF\n$(cat \\\n.env)\nEOF', "cat <<A <<'B'\n$(cat .env)\nA\nx\nB"]) {
240
+ ok(`Bash \`${c}\` is denied with path .env (#218)`, hit214(c));
241
+ }
242
+ for (const c of ["cat <<'EOF'\n$(cat .env)\nEOF", 'cat <<"EOF"\n$(cat .env)\nEOF', "cat <<'EOF'\n`cat .env`\nEOF",
243
+ 'cat <<EOF\n$$(cat .env)\nEOF', "cat <<A <<'B'\nx\nA\n$(cat .env)\nB",
244
+ 'cat <<EOF\n\\$(cat .env)\nEOF', 'cat <<EOF\n\\`cat .env\\`\nEOF', 'cat <<EOF\ncat .env\nEOF']) {
245
+ allowCmd(c);
246
+ }
247
+
248
+ // #223: a heredoc body fed to a shell interpreter on stdin is a script, whatever the delimiter quoting
249
+ for (const c of ["bash <<'EOF'\ncat .env\nEOF", 'sh <<EOF\ncat .env\nEOF', 'bash <<EOF\ncat .env\nEOF',
250
+ 'cat <<EOF | bash\ncat .env\nEOF', 'zsh -s <<EOF\ncat .env\nEOF', 'VAR=1 bash <<EOF\ncat .env\nEOF',
251
+ 'env FOO=1 bash <<EOF\ncat .env\nEOF', '/bin/bash <<EOF\ncat .env\nEOF', 'dash <<EOF\ncat .env\nEOF',
252
+ 'env -i FOO=1 ksh <<EOF\ncat .env\nEOF', "cat <<'EOF' | sh\ncat .env\nEOF", 'bash -x <<EOF\necho $(cat .env)\nEOF',
253
+ 'echo hi; bash <<EOF\ncat .env\nEOF']) {
254
+ ok(`Bash \`${c}\` is denied with path .env (#223)`, hit214(c));
255
+ }
256
+ // #223 review: env option values, leading shell keywords, heredoc pairing
257
+ for (const c of ['env -u FOO bash <<EOF\ncat .env\nEOF', 'env -C /tmp bash <<EOF\ncat .env\nEOF',
258
+ 'env --unset FOO sh <<EOF\ncat .env\nEOF', 'env --chdir /tmp bash <<EOF\ncat .env\nEOF',
259
+ 'env -S bash <<EOF\ncat .env\nEOF', 'if true; then bash <<EOF\ncat .env\nEOF', 'time bash <<EOF\ncat .env\nEOF',
260
+ 'while x; do bash <<EOF\ncat .env\nEOF', '! bash <<EOF\ncat .env\nEOF', 'true; then time bash <<EOF\ncat .env\nEOF',
261
+ 'cat <<EOF |& bash\ncat .env\nEOF', 'bash <<EOF &\ncat .env\nEOF', 'cat <<A; bash <<B\necho hi\nA\ncat .env\nB']) {
262
+ ok(`Bash \`${c}\` is denied with path .env (#223 review)`, hit214(c));
263
+ }
264
+ allowCmd('cat <<A; bash <<B\ncat .env\nA\necho hi\nB');
265
+
266
+ for (const c of ["cat <<'EOF'\ncat .env\nEOF", 'cat <<EOF\ncat .env\nEOF', "tee f <<'EOF'\ncat .env\nEOF",
267
+ 'cat <<EOF; bash x.sh\ncat .env\nEOF', "bash <<'EOF'\necho hi\nEOF", 'cat <<EOF | bash\necho hi\nEOF']) {
268
+ allowCmd(c);
269
+ }
270
+
271
+ // #214: deep nesting is bounded (explicit work-list, capped), never a stack overflow
272
+ {
273
+ const nest = (n, inner) => 'echo ' + '"$('.repeat(n) + inner + ')"'.repeat(n);
274
+ for (const n of [40, 2000, 6000]) {
275
+ const t0 = Date.now();
276
+ const r = detectSecretRead('Bash', { command: nest(n, 'cat .env') });
277
+ const ms = Date.now() - t0;
278
+ ok(`nesting n=${n} with a secret is denied with path .env`, !!r && r.path === '.env');
279
+ ok(`nesting n=${n} with a secret completes under 1s (${ms}ms)`, ms < 1000);
280
+ }
281
+ const t1 = Date.now();
282
+ const clean = detectSecretRead('Bash', { command: nest(6000, 'cat README.md') });
283
+ const ms1 = Date.now() - t1;
284
+ ok('deep nesting with no secret is allowed', clean === null);
285
+ ok(`deep nesting with no secret completes under 1s (${ms1}ms)`, ms1 < 1000);
286
+ }
287
+
183
288
  const a6 = runHook('Bash', { command: 'git check-ignore -v .env' });
184
289
  ok('hook allows Bash(git check-ignore -v .env) end-to-end (exit 0, no output)', a6.code === 0 && a6.out.trim() === '');
185
290
  const a7 = runHook('Bash', { command: 'cp .env.example .env' });
186
291
  ok('hook allows Bash(cp .env.example .env) end-to-end (exit 0, no output)', a7.code === 0 && a7.out.trim() === '');
187
292
 
293
+ // --- #227: constructs the guard cannot interpret, near a secret-looking piece ---
294
+ {
295
+ const CODE = 'SECRET_READ_UNMODELLED_CONSTRUCT';
296
+ const code = (command) => { const r = runHook('Bash', { command }); return r.code === 2 ? JSON.parse(r.out).code : null; };
297
+ for (const c of [
298
+ 'eval cat .e""nv',
299
+ 'eval "cat .e\'\'nv"',
300
+ 'echo ${ cat .e""nv; }',
301
+ 'echo ${| cat .e""nv; }',
302
+ 'source .e""nv',
303
+ '. .e""nv && make',
304
+ 'printf -v x %s .e""nv',
305
+ "alias c='cat .e\"\"nv'",
306
+ "alias c='cat .env'; c",
307
+ 'f() { cat .e""nv; }; f',
308
+ 'echo "${ cat .e""nv; }"',
309
+ 'x=$(source .e""nv)',
310
+ ]) {
311
+ const hit = detectSecretRead('Bash', { command: c });
312
+ ok(`denies an unmodelled construct near a secret piece: ${c}`, !!hit && !!hit.unmodelled);
313
+ ok(`... with the unmodelled code (exit 2): ${c}`, code(c) === CODE);
314
+ }
315
+ ok('a quoted api body key mentioning .env in prose is not a read', detectSecretRead('Bash', { command: "gh api -X PATCH repos/a/b/issues/1 -f 'body'='document the .env file'" }) === null);
316
+ ok('... and the hook allows it', runHook('Bash', { command: "gh api -X PATCH repos/a/b/issues/1 -f 'body'='document the .env file'" }).code === 0);
317
+ // The precise scan keeps priority and its code.
318
+ ok('a precise read keeps SECRET_READ_BLOCKED', code('eval cat .env') === 'SECRET_READ_BLOCKED');
319
+ ok('a precise read inside a function body keeps SECRET_READ_BLOCKED', code('f() { cat .env; }; f') === 'SECRET_READ_BLOCKED');
320
+ ok('detectSecretRead keeps { path } for a precise read', (() => { const h = detectSecretRead('Bash', { command: 'cat .env' }); return h && h.path === '.env' && !h.unmodelled; })());
321
+ // Plain controls pass: the construct has no secret-looking piece near it.
322
+ for (const c of [
323
+ '. venv/bin/activate && make',
324
+ 'source ~/.venv/bin/activate && pytest',
325
+ "alias ll='ls -l'",
326
+ 'alias g=gh; g issue list',
327
+ 'eval "$(ssh-agent)"',
328
+ 'printf -v x %s y; echo $x',
329
+ 'f() { ls; }; f',
330
+ 'echo ${ date; }',
331
+ 'echo "alias .environment"',
332
+ 'cat .e\\\n nv.md',
333
+ ]) ok(`allows a construct with no secret piece: ${c}`, detectSecretRead('Bash', { command: c }) === null && runHook('Bash', { command: c }).code === 0);
334
+ const r = runHook('Bash', { command: 'echo ${ cat .e""nv; }' });
335
+ const reason = JSON.parse(r.out).reason;
336
+ ok('the message names the guard and asks for the simple form', /^trailhead secret-read guard: /.test(reason) && /Rewrite it in a simple form/.test(reason));
337
+ const T = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'.slice(0, 36);
338
+ const leak = JSON.parse(runHook('Bash', { command: `echo \${ cat .e""nv; echo ${T}; }` }).out).reason;
339
+ ok('the message never echoes a token', !leak.includes(T) && !leak.includes('ghp_'));
340
+ }
341
+
188
342
  // --- crash safety ---
189
343
  const crash1 = runHook('Read', undefined);
190
344
  ok('missing tool_input never crashes (exit 0)', crash1.code === 0);
191
345
 
346
+ // --- #227: a missing unmodelled lib degrades to the previous behaviour ---
347
+ {
348
+ const fs = require('fs');
349
+ const os = require('os');
350
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'srg-nolib-'));
351
+ fs.mkdirSync(path.join(dir, 'lib'));
352
+ for (const f of fs.readdirSync(path.join(__dirname, 'lib'))) {
353
+ if (/\.(js|json)$/.test(f) && !f.endsWith('.test.js') && f !== 'unmodelled.js') fs.copyFileSync(path.join(__dirname, 'lib', f), path.join(dir, 'lib', f));
354
+ }
355
+ fs.copyFileSync(path.join(__dirname, 'trailhead-secret-read-guard.js'), path.join(dir, 'trailhead-secret-read-guard.js'));
356
+ const bare = require(path.join(dir, 'trailhead-secret-read-guard.js'));
357
+ ok('without the unmodelled lib the guard still loads and denies a precise read', !!bare.detectSecretRead('Bash', { command: 'cat .env' }));
358
+ ok('without the unmodelled lib the unmodelled gate is off', bare.detectSecretRead('Bash', { command: 'source .e""nv' }) === null);
359
+ fs.rmSync(dir, { recursive: true });
360
+ }
361
+
192
362
  console.log(`✓ secret-read-guard: ${passed} assertions passed`);
@@ -4,6 +4,8 @@ When trailhead must put a **forced choice** to the human (any `AskUserQuestion`-
4
4
 
5
5
  **Host constraint.** These options exist only inside questions **trailhead itself authors**. The host `AskUserQuestion` tool is not ours to change; we add the options as extra choices in the prompts we build.
6
6
 
7
+ **Which format.** Count the **real options** (the delegate/defer affordances never count). A forced choice with **up to 4 real options** is always put as an `AskUserQuestion` menu, never as a prose list: the menu rules below apply (dedup, cap, links in the prose). A forced choice with **more than 4 real options** cannot fit the menu, so put it as a **lettered list** in prose, one option per line (**(a)**, **(b)**, ...), with any applicable affordance appended as its own letter, then wait for the user's pick (a letter or the option's text). The lettered `(a)/(b)/(c)` options written in these instructions name the options to offer, not the format: render them per this rule. Conversational call-sites (grilling) stay in prose. On Codex, which has no `AskUserQuestion`, the plain-text numbered list of its adapter replaces the menu.
8
+
7
9
  ## The one-line test: whose choice is it?
8
10
 
9
11
  Before offering the delegate option, ask: **is the answer itself a project decision (a `decision`-ticket's answer, or a change to the map's destination or scope), as opposed to a process choice that merely gets folded into some ticket's ordinary resolution summary?**
@@ -54,7 +56,7 @@ This is the operational recipe every applicable call-site follows. It is **manda
54
56
 
55
57
  Worked examples: 2 real options + delegate + defer = 4 -> all in the menu; 3 real options + delegate + defer = 5 -> the menu carries the 3 real options and delegate/defer are offered in the prose line right after it; 2 real options + delegate (defer not applicable) = 3 -> in the menu.
56
58
 
57
- **Menu call-sites (an `AskUserQuestion` trailhead authors): links live in the prose.** A picker renders its question and options as plain text, so nothing in it is clickable. When a question is about something the user has to open first (a mockup to approve or pick from, a published page, a PR, a ticket comment), write its clickable link(s) in the reply text **immediately before** the menu, in that same message, never only earlier in the conversation, never as a name in place of the URL, and never inside the picker. If there is no link to show yet, do not ask. For mockups the full rule is "Surface the mockup" in `techniques/prototype.md`, enforced by the mockup-link hooks (Claude Code and Codex).
59
+ **Menu call-sites (an `AskUserQuestion` trailhead authors): links live in the prose.** A picker renders its question and options as plain text, so nothing in it is clickable. When a question is about something the user has to open first (a mockup to approve or pick from, a published page, a PR, a ticket comment), write its clickable link(s) in the reply text **immediately before** the menu, in that same message, never only earlier in the conversation, never as a name in place of the URL, and never inside the picker. If there is no link to show yet, do not ask. For mockups the full rule is "Surface the mockup" in `techniques/prototype.md`, enforced by the mockup-link hooks (Claude Code and Codex); the Claude Code guard also accepts a link carried by the approval question itself, but the prose stays the preferred form.
58
60
 
59
61
  **Prose / conversational call-sites (grilling, or any choice put in words rather than a menu).** There is no cap: **state the applicable affordance(s) in words** in the same message that puts the choice (for example append "Oppure: scegli tu, propongo io / scelgo dopo se dipende da un prerequisito."). Grilling stays human-decides, so delegate is not offered there; **defer is offered in prose when the decision is genuinely blocked on a prerequisite.**
60
62
 
@@ -13,7 +13,7 @@ gh issue list --label "trailhead:ticket" --label "trailhead:map-<n>" --state ope
13
13
  --search "no:assignee -label:trailhead:blocked -label:trailhead:unverified"
14
14
  ```
15
15
 
16
- **Active map.** Which map a session works is the **active map**: a `.trailhead/active-map` marker at the working root (one line `#<n>`, **gitignored**, per-session local state like `session-ticket`, never committed). `/trailhead:work` and `/trailhead:map` default to it; naming a map/ticket explicitly always overrides. Each checkout/clone (so each teammate) has its own.
16
+ **Active map.** Which map a session works is the **active map**: a `.trailhead/active-map` marker at the working root (one line `#<n>`, **gitignored**, per-session local state like `session-ticket`, never committed). `/trailhead:work` and `/trailhead:map` default to it; naming a map/ticket explicitly always overrides. A named ticket that carries a `trailhead:map-<n>` label makes that map the active map (write `.trailhead/active-map`); a whiteboard ticket leaves the active map alone. Each checkout/clone (so each teammate) has its own.
17
17
 
18
18
  **Park & switch** = **pause** the in-flight ticket (`/trailhead:pause`, a `PAUSED` checkpoint), then point the active map at the other's number. Nothing else to save: every map lives on the Issues, durable. Resume later with `/trailhead:resume`, or just `/trailhead:work` once that map is active again.
19
19
 
@@ -1,6 +1,6 @@
1
1
  ## Session handoff
2
2
 
3
- Resolving a ticket **ends the session**: one ticket per session is a hard rule (see [Principles](principles.md)). So every resolution closes the same way. Render every command in this ritual in this host's command forms (the **Host command forms** rule in `load-first.md`). After the resolution comment + `gh issue close` + the `Decisions so far` update, **remove the session-ticket marker** (`.trailhead/session-ticket` at the working root, if you wrote one, see Mode 2), then **always** sign off with, in order:
3
+ Resolving a ticket **ends the session**: one ticket per session is a hard rule (see [Principles](principles.md)). So every resolution closes the same way. Render every command in this ritual in this host's command forms (the **Host command forms** rule in `load-first.md`). After the resolution comment + `gh issue close` + the `Decisions so far` update, **remove the session-ticket marker** (`.trailhead/session-ticket` at the working root, if you wrote one, see Mode 2) and **clear the work state** (`node <templates>/trailhead-work-state.js clear --repo <working-root>`, see **Work state file** in `teamwork.md`), then **always** sign off with, in order:
4
4
 
5
5
  1. **A short recap of what was done** (2 to 4 lines) for the resolved ticket: the change in plain language, the files or areas touched (when applicable), and the outcome (verified / released / next step). Keep it **result-oriented**: say what changed and what it means, never trailhead's internal machinery (no mode or step names, no engine-role or technique names). It **augments** the ritual (it does not replace anything below): the recap sits above the one-line confirmation and gives the user a quick account of the ticket without reconstructing it from the commits or the issue timeline. It rides with a **resolution** only; a decision-ticket resolution may touch no files, hence "when applicable", and a capture or research batch (which never reach this ritual) carry no recap. It also carries forward any **pending follow-up** the Pending follow-up guard below identified, so a follow-up surfaced while working never has to be reconstructed from scrollback.
6
6
  2. **A one-line confirmation** the ticket is resolved, by name and number, both visible (e.g. *Add login rate limit* (#45), never the name alone nor a bare `#number`).
@@ -49,8 +49,8 @@ With the default `auto` this choice is made for you at each ticket's work-start;
49
49
 
50
50
  ## Pausing & resuming
51
51
  Switching away from a ticket mid-work (to clear a blocker, chase an urgent bug, or just stop) must not lose the thread.
52
- - **Pause** (`/trailhead:pause [note]`): first commit any work-in-progress (atomic) so nothing is stranded, then post a **`PAUSED`** checkpoint comment: what's done, the exact next step, and any local state (branch, **worktree path under `isolation: worktree`**, how to run it). Keep the claim if you'll return soon; **release it** (unassign) if it's open-ended, so someone else can resume; the checkpoint is what makes that safe. If you release, also remove the `.trailhead/session-ticket` marker (SKILL.md Mode 2); if you keep the claim, leave it. **If the paused ticket is a `trailhead:whiteboard` quick that has deferred its dashboard refresh to a handoff it is not reaching this session** (born by `quick`, resolution refresh not yet done), refresh the dashboard now (full regeneration from the live tracker) so the unfinished quick shows on the pinned index: the read-only renders never rewrite the dashboard body, so pause is what surfaces it.
53
- - **Resume** (`/trailhead:resume [ticket]`): read the ticket's latest `PAUSED` checkpoint (and the `DISCUSS`/`PLAN` comments above it), then pick up at the recorded next step. Anyone may resume a *released* ticket; a still-claimed one, only its owner. **Re-write the `.trailhead/session-ticket` marker** at the working root (SKILL.md Mode 2 step 2) so the resumed session shows in tooling like the statusline (a released pause removed it); under `isolation: worktree`/`clone`, also re-enter the ticket's recorded workspace.
52
+ - **Pause** (`/trailhead:pause [note]`): first commit any work-in-progress (atomic) so nothing is stranded, then post a **`PAUSED`** checkpoint comment: what's done, the exact next step, and any local state (branch, **worktree path under `isolation: worktree`**, how to run it). Keep the claim if you'll return soon; **release it** (unassign) if it's open-ended, so someone else can resume; the checkpoint is what makes that safe. If you release, also remove the `.trailhead/session-ticket` marker (SKILL.md Mode 2); if you keep the claim, leave it. Either way, set the work state `--step paused` and keep the file. **If the paused ticket is a `trailhead:whiteboard` quick that has deferred its dashboard refresh to a handoff it is not reaching this session** (born by `quick`, resolution refresh not yet done), refresh the dashboard now (full regeneration from the live tracker) so the unfinished quick shows on the pinned index: the read-only renders never rewrite the dashboard body, so pause is what surfaces it.
53
+ - **Resume** (`/trailhead:resume [ticket]`): read the ticket's latest `PAUSED` checkpoint (and the `DISCUSS`/`PLAN` comments above it), then pick up at the recorded next step. Anyone may resume a *released* ticket; a still-claimed one, only its owner. **Re-write the `.trailhead/session-ticket` marker** at the working root (SKILL.md Mode 2 step 2) so the resumed session shows in tooling like the statusline (a released pause removed it), and set the work state to the recorded next step, in the full work-start form (`--map`, `--ticket`, `--title`, `--step <next step>`) so the file is re-created when absent; under `isolation: worktree`/`clone`, also re-enter the ticket's recorded workspace.
54
54
  - One ticket per session still holds: pausing A to work B ideally means B in a fresh session. If you must switch within one session, checkpoint A first; never leave it half-done and unrecorded.
55
55
 
56
56
  ## Parking & switching between maps
@@ -97,3 +97,5 @@ The map issue's **body** is a shared mutable document: two sessions appending to
97
97
  **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:')`.
98
98
 
99
99
  **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 (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 (recognised by its own header line, not by trusting the filename) -> upgraded in place; 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).` among its first 5 lines, trimmed; never trust the filename alone; a symlinked `commit-msg` counts as foreign, as the script treats it). Report one result-oriented line: (a) trailhead hook present and executable (`test -x`): git does enforce trailhead's rules here, 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.
100
+
101
+ **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. 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).
@@ -30,7 +30,7 @@ If genuinely ambiguous and the user's unreachable, default by context (backend m
30
30
 
31
31
  Either way the rough screen is **reacted to before real UI code**.
32
32
 
33
- **"Surface the mockup" means: put its link in the same message that asks for the reaction.** The user can't react to what they can't open. Every message that asks to approve, react to, or pick between mockups **must carry, in that same message and right before the question**, the direct clickable location of each screen it refers to: the file path (`disk`), the canvas Artifact URL plus the **artboard title** of each screen (`artifacts`: one canvas URL, then one line per screen or variant naming its artboard and page, since artboards have no URL of their own), or the Stitch project/screen URL (`stitch`). A link given earlier in the conversation, only in a ticket comment, or implied ("the mockup above") does **not** count: scrollback scrolls away. **Write the link(s) in the plain prose of the message, never inside a structured prompt**: an options picker renders its question and options as plain text, so a link put there is not clickable (above all on mobile). When the approval then goes through a structured prompt, keep every link in the prose just before it and let the picker ask only a plain yes/no: approve the mockup (yes) or request changes (no); for a pick between variants, name each variant in the options and keep their links in the prose. If you don't have a link to show yet (publish failed, URL not returned), **do not ask for approval**: fix the publish or report the failure first. The project's or canvas's **name is not a link** ("the mockup is ready on MyApp" without the URL fails this rule), and the link must sit in the reply **text**, not only in your reasoning. A hook enforces this: on Claude Code the **mockup-link guard** blocks a mockup-approval `AskUserQuestion` whose preceding reply text carries no URL or file path; on Codex **mockup-link-stop** continues a final reply that asks for the approval without one. When either fires, write the link(s) in the prose and ask again.
33
+ **"Surface the mockup" means: put its link in the same message that asks for the reaction.** The user can't react to what they can't open. Every message that asks to approve, react to, or pick between mockups **must carry, in that same message and right before the question**, the direct clickable location of each screen it refers to: the file path (`disk`), the canvas Artifact URL plus the **artboard title** of each screen (`artifacts`: one canvas URL, then one line per screen or variant naming its artboard and page, since artboards have no URL of their own), or the Stitch project/screen URL (`stitch`). A link given earlier in the conversation, only in a ticket comment, or implied ("the mockup above") does **not** count: scrollback scrolls away. **Write the link(s) in the plain prose of the message, never inside a structured prompt**: an options picker renders its question and options as plain text, so a link put there is not clickable (above all on mobile). When the approval then goes through a structured prompt, keep every link in the prose just before it and let the picker ask only a plain yes/no: approve the mockup (yes) or request changes (no); for a pick between variants, name each variant in the options and keep their links in the prose. If you don't have a link to show yet (publish failed, URL not returned), **do not ask for approval**: fix the publish or report the failure first. The project's or canvas's **name is not a link** ("the mockup is ready on MyApp" without the URL fails this rule), and the link must sit in the reply **text**, not only in your reasoning. A hook enforces this: on Claude Code the **mockup-link guard** blocks a mockup-approval `AskUserQuestion` whose preceding reply text carries no URL or file path (it also accepts a link carried by the approval question itself, but the prose stays the preferred form since a picker's text is not clickable); on Codex **mockup-link-stop** continues a final reply that asks for the approval without one. When either fires, write the link(s) in the prose and ask again.
34
34
 
35
35
  How approval gates the build depends on `config.design.approval`:
36
36
  - **`explicit` (default)**: surface the mockup and **wait for the user's clear go-ahead**; do not write UI code until they approve. Record the approval on the ticket, then proceed.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: trailhead
3
3
  description: "Start and drive a large project (more than one agent session can hold) as a map of decision tickets on GitHub Issues, resolving one at a time until the way to the destination is clear. Self-contained (zero external-plugin dependencies: trailhead ships its own `trailhead-*` engine agents and needs only an authenticated gh CLI): map-based onboarding and a discuss→plan→execute→verify engine with atomic commits, with everything (map, discussion, plan, verification) living on the Issues. Invoke only when the user explicitly runs `/trailhead` (or a `/trailhead:<verb>` command) or asks to chart, adopt, or work a trailhead map, and do not auto-invoke for generic planning, project, or task-tracking requests."
4
- argument-hint: "[new|adopt|work|quick|whiteboard|ticket|inbox|map|dashboard|config|grill|split|pause|resume|auto|update|prune|audit|bug|todo|idea|seed|note] [text | ticket]"
4
+ argument-hint: "[new|adopt|work|quick|whiteboard|ticket|inbox|map|dashboard|config|grill|split|pause|resume|auto|update|prune|audit|panel|bug|todo|idea|seed|note] [text | ticket]"
5
5
  ---
6
6
 
7
7
  `trailhead` charts a large project as a **shared map on GitHub Issues**, then works its **tickets** one at a time until the way to the destination is clear. This skill is the **entry point**: bare `/trailhead` runs **smart entry** (detect the repo state and propose the next move); a verb runs that verb's engine. Everything lives on the Issues; the repo holds code only.
@@ -15,7 +15,7 @@ Before doing anything, read `../_shared/load-first.md` and follow it: the shared
15
15
  The **first word** of the arguments is the verb; the rest is the text or a ticket number. `/trailhead <verb>` and `/trailhead:<verb>` are equivalent.
16
16
 
17
17
  - **No verb** to **smart entry** (below): this skill handles it directly.
18
- - **A verb** to hand off to the skill that owns it, passing the whole argument string. **The skill-split migration ([map #53](https://github.com/ToRvaLDz/trailhead/issues/53)) lands the cluster skills one at a time.** The **chart cluster is built (#59)**: `new`, `adopt`, `ticket`, `inbox`, and `grill` route to the **`trailhead-chart`** skill (call the Skill tool with skill name `trailhead-chart` and arguments `<verb> <rest>`). The **work cluster is built (#60)**: `work`, `quick`, `pause`, `resume`, `split`, and `auto` route to the **`trailhead-work`** skill (call the Skill tool with skill name `trailhead-work` and arguments `<verb> <rest>`). The **view cluster is built (#61)**: `map`, `dashboard`, and `whiteboard` route to the **`trailhead-view`** skill (call the Skill tool with skill name `trailhead-view` and arguments `<verb> <rest>`). The **capture cluster is built (#62)**: `bug`, `todo`, `idea`, `seed`, and `note` route to the **`trailhead-capture`** skill (call the Skill tool with skill name `trailhead-capture` and arguments `<verb> <rest>`). The **manage cluster is built (#63)**: `config`, `update`, `prune`, and `audit` route to the **`trailhead-manage`** skill (call the Skill tool with skill name `trailhead-manage` and arguments `<verb> <rest>`). Then carry out the target skill's instructions.
18
+ - **A verb** to hand off to the skill that owns it, passing the whole argument string. **The skill-split migration ([map #53](https://github.com/ToRvaLDz/trailhead/issues/53)) lands the cluster skills one at a time.** The **chart cluster is built (#59)**: `new`, `adopt`, `ticket`, `inbox`, and `grill` route to the **`trailhead-chart`** skill (call the Skill tool with skill name `trailhead-chart` and arguments `<verb> <rest>`). The **work cluster is built (#60)**: `work`, `quick`, `pause`, `resume`, `split`, and `auto` route to the **`trailhead-work`** skill (call the Skill tool with skill name `trailhead-work` and arguments `<verb> <rest>`). The **view cluster is built (#61)**: `map`, `dashboard`, and `whiteboard` route to the **`trailhead-view`** skill (call the Skill tool with skill name `trailhead-view` and arguments `<verb> <rest>`). The **capture cluster is built (#62)**: `bug`, `todo`, `idea`, `seed`, and `note` route to the **`trailhead-capture`** skill (call the Skill tool with skill name `trailhead-capture` and arguments `<verb> <rest>`). The **manage cluster is built (#63)**: `config`, `update`, `prune`, and `audit` route to the **`trailhead-manage`** skill (call the Skill tool with skill name `trailhead-manage` and arguments `<verb> <rest>`). The **`panel`** verb routes to no skill: it is answered by the trailhead mod on Claude Code 2.1.287+ (`/trailhead:panel` opens or closes the live map panel), and where no mod answers, say in one line that the panel is not available in this session. Then carry out the target skill's instructions.
19
19
  - **Unrecognised verb** to treat the whole string as `idea` (route to `trailhead-capture` as `idea <text>`).
20
20
 
21
21
  The namespaced `/trailhead:<verb>` commands reach their target skill directly through their own command wrapper; this routing table only covers the bare `/trailhead <verb>` form.
@@ -37,13 +37,14 @@ This guard is the **explicit-number paths only**. Bare `work` selects from the l
37
37
 
38
38
  The user invokes with a map (URL or number). A ticket is optional: without one, you pick the next decision.
39
39
 
40
- 1. Load the **map** (the low-res view, not every ticket body). **Pick which map** if the repo has several open: use the one the user named, else the active map (`.trailhead/active-map`); if neither and more than one is open, list them and ask. Set/refresh the active-map marker to the one you're working. See **Multiple maps on one repo** in `../_shared/multi-map.md`. **Codebase issue check (greenfield, offer once):** if the repo has **no `trailhead:codebase` issue** (a greenfield map never got one) and now holds **substantial code**, **don't interrupt the work to offer now**: mark the offer **pending** and surface it **at the handoff**, just before the `/clear`-first next-step block (not as a line inside it: see the **pending codebase offer** rule in `../_shared/session-handoff.md`), exactly once. On the user's yes, generate it via the **Codebase map** technique, then pin it (one of the three permanent repo pins: dashboard, codebase, conventions) and link it from the map's Notes; if the user declines, don't nag again this session. A repo that already has the issue, or is still too thin to map, skips this.
40
+ 1. Load the **map** (the low-res view, not every ticket body). **Pick which map** if the repo has several open: use the one the user named, else the active map (`.trailhead/active-map`); if neither and more than one is open, list them and ask. Set/refresh the active-map marker to the one you're working. **Named ticket:** its map becomes the active map, a whiteboard ticket leaves it alone (see **Active map** in `../_shared/multi-map.md`, under **Multiple maps on one repo**). **Codebase issue check (greenfield, offer once):** if the repo has **no `trailhead:codebase` issue** (a greenfield map never got one) and now holds **substantial code**, **don't interrupt the work to offer now**: mark the offer **pending** and surface it **at the handoff**, just before the `/clear`-first next-step block (not as a line inside it: see the **pending codebase offer** rule in `../_shared/session-handoff.md`), exactly once. On the user's yes, generate it via the **Codebase map** technique, then pin it (one of the three permanent repo pins: dashboard, codebase, conventions) and link it from the map's Notes; if the user declines, don't nag again this session. A repo that already has the issue, or is still too thin to map, skips this.
41
41
  2. Choose the ticket. **If the user named a ticket, the Preflight above already confirmed it is open** (a closed, superseded, out-of-scope, or nonexistent number stopped there, before any setup ran). If the user names one, use it (a **named** ticket may be a `trailhead:whiteboard` one: `work <n>` works it whole, off the map, with the normal cycle and split allowed; use `quick <n>` for the no-split variant). Otherwise take a frontier ticket to act on, **scoped to this map** (add its `trailhead:map-<n>` label to the frontier query when several maps are live) and **never a whiteboard ticket** (bare `work` stays on the map; the whiteboard frontier is reached via `/trailhead:whiteboard` and `quick`). Bare `work` may act on the first ticket the query returns, but that order is `gh`'s newest-first, not a priority: present several independent tickets as a **set to choose from**, not the graph's natural next step (see **Frontier order carries no priority** in `../_shared/substrate.md`). **Claim** it: assign it to yourself before any work, then re-read the assignee before starting; on a collision, stop and ask the user (see `../_shared/teamwork.md`). If the ticket proves too big once you're in it, propose a **split** rather than grind, but never split on your own: present the children you would create and wait for the user's go-ahead before creating anything (same file).
42
42
  - **First, resolve the effective isolation mode.** The `isolation:` header defaults to **`auto`** (an absent key means `auto`; the engine never writes the key). Under **`auto`**, at work-start **before any file edit or diagnostic probe**, first run the read-only **assigned-ticket pre-check** (full rule in `../_shared/teamwork.md`): other open tickets already assigned to you **pre-bias** the ask toward `worktree`/`clone` (still asking, easy neutral), and the ask is **skipped** only when the session is already inside this ticket's own linked worktree (`trailhead/t<N>`, `N` the current ticket). Then ask the user once whether they will work more than one ticket at once on this machine this session. **No** (a lone ticket): the mode resolves to the current checkout, exactly like `none` (the scope-free check below applies). **Yes**: resolve by repo shape, a plain repo -> `worktree`, a **superrepo** (a `.gitmodules` at the checkout root declaring at least one submodule) -> `clone`; that "yes" **is** the clone gate's answer, so the resolved `clone` path proceeds without re-asking. Because trailhead resolves one ticket per session, this asks at most once per ticket work-start (so once per session in normal work; a within-session ticket switch re-asks at the new ticket's work-start), and an autonomous run (`/trailhead:auto`) never asks and never runs the pre-check (sequential work, no concurrency) and resolves to the current checkout. If the header carries an **explicit** `none`/`worktree`/`clone`, honour it as-is (never subject it to auto's ask): explicit `clone` keeps its own gate, explicit `worktree` is eager. See `../_shared/teamwork.md`.
43
43
  - **When the resolved mode is the current checkout (`isolation: none`, or `auto` answered no), check the scope is free before starting** (this is how a submodule/package gets serialised when worktrees don't fit): scan the other in-progress tickets' `Scope:` lines, and if one **overlaps** this ticket's scope, **stop and tell the user** the scope is busy (name the ticket + holder) and offer a disjoint-scope frontier ticket or to wait. See `../_shared/teamwork.md`.
44
44
  - **Write the session-ticket marker**: at the **working root** (the checkout, worktree, or clone you'll edit in), write `.trailhead/session-ticket` as **one line** `#<n> <ticket title>`. **Keep it gitignored** (add `.trailhead/session-ticket` to that repo's `.gitignore` if absent): it's per-session local state, **never committed**. It lets an external tool show "what am I on" offline (the tracker stays the source of truth), but it is not merely that cheap hint: while it is present, the git commit-msg hook also requires every commit to carry a `Refs: #<n>` trailer matching it, so a stale marker left behind would wrongly demand that trailer on unrelated commits. Remove it at Resolve/handoff (see `../_shared/session-handoff.md`); on **Pause** it may stay (you'll return) or be cleared if you release the claim. The **`.trailhead/active-map`** marker (which map this session works) follows the same rules: gitignored, per-session, never committed; see **Multiple maps on one repo** in `../_shared/multi-map.md`.
45
45
  - **Sync the commit-msg hook**: at the working root, run `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>` (where `<templates>` lives and what each status means: the commit-msg hook paragraph in `../_shared/teamwork.md`). Stay silent when it reports installed or current. On **upgraded**, give the user one result-oriented line: this clone's commit-msg hook was updated to the current rules. On **foreign**, one line that the user's own hook was left untouched and git does not enforce trailhead's rules here. On **skipped**, one line that nothing was set up here (a `core.hooksPath` without a trailhead hook, or a working root that is not a git repo) and git does not enforce trailhead's rules. On a missing script or a node error (missing means none of the three template dirs has it: try them all before concluding that), inspect the hooks dir (`git rev-parse --git-path hooks`) and report per the fallback in that same paragraph of `../_shared/teamwork.md` (trailhead hook present and executable: enforced but freshness unchecked, update the install; present but not executable: git skips it, not enforced; foreign hook: untouched, not enforced; no hook: not enforced, update the install), never a blind "not enforced"; it never blocks the work.
46
46
  - **When the resolved mode is `worktree` or `clone` (explicit, or `auto` answered yes), set up the isolated workspace NOW, before any file edit or diagnostic probe** (not later at Execute/Fix): **if the repo has submodules (a `.gitmodules`), `isolation: worktree` is escalated to `clone`** (a superproject worktree cannot safely carry submodule state; see `../_shared/teamwork.md`). For `worktree` (no submodules), create/enter this ticket's `git worktree` on a `trailhead/t<n>` branch for the package/path the ticket's `Scope:` names (a submodule-bearing repo would have escalated to `clone` above), e.g. `git worktree add ../<repo>-t<n> -b trailhead/t<n>`; for `clone`, make an independent working copy at `../<repo>-t<n>` (branch `trailhead/t<n>`) by whichever is faster: `git clone` (add `--recurse-submodules` when the repo has submodules) + the install step, or a **folder copy that brings `node_modules`** (and the submodule working trees) to skip the reinstall (then verify the copy's git is independent, `git -C <copy> rev-parse --absolute-git-dir` resolves inside it, and the same inside every submodule, else commits leak back to the original); a clone with submodules is provisioned via `git submodule update --init --recursive` (nested included), each scoped submodule pinned at the superproject's recorded commit. **If this `clone` was reached via `auto`'s work-start ask (answered yes), that ask already served as the clone gate: do not re-ask, proceed to set up the clone. Under an explicit `isolation: clone`, run the gate as follows.** **Because `clone` is heavy (a full clone + an install), gate it, every time, before cloning: ask the user whether they expect to work more than one ticket at once on this machine this session.** If **no**, **skip the clone** and work this ticket in the current checkout (isolation only exists to stop concurrent workspaces from colliding; a lone ticket has nothing to collide with, so the shared-checkout caveats apply and that is fine, and `git:` still decides the branch as usual); if **yes**, clone as above. The gate is **`clone`-only**: `worktree` is cheap, so set it up without asking. Do every subsequent step in whatever workspace you ended up in. This is the whole point of isolation: if you diagnose and edit in the shared checkout "just to start" while a concurrent workspace exists, you are already colliding. Set the workspace up silently (the `clone` gate-question is the one exception you voice); it's undone at Resolve when the branch integrates and the worktree/clone is removed. See `../_shared/teamwork.md`.
47
+ - **Write the work state, last** (the working root is final only now): `node <templates>/trailhead-work-state.js set --repo <working-root> --map <n> --ticket <n> --title "<title>" --step discuss`, and add `.trailhead/work-state.json` to that repo's `.gitignore` if absent. It is updated at every step change and cleared at Resolve; the canonical description, the `<templates>` lookup, and the silent-on-failure rule are the **Work state file** paragraph in `../_shared/teamwork.md`, and the step mapping per type is in `references/ticket-engines.md`.
47
48
  3. **Brief the ticket and confirm scope, before the engine runs.** With the ticket claimed, read it and give the user a **brief, result-oriented summary of what resolving it will do** (its goal and the shape of the work, not a plan yet), then **ask whether they want to change anything**. This is a HITL confirm gate: on assent, proceed to the engine. **If the user asks for a change, hold it against the ticket.** A change **consistent** with the ticket is folded in and the engine proceeds. A change that **diverges** from the ticket is surfaced as a mismatch, and the user is **asked, never forced**, whether to (a) work the ticket **as-is**, or (b) open a **separate new ticket** for the divergent request. Offer both and decide neither: open the separate ticket **only if the user chooses it** (via **Scope that surfaces while working a ticket**, served by the capture cluster, the same capture-or-split path used for scope that surfaces mid-work), never automatically. **If the user defers the choice rather than settling it here, mark the divergent follow-up pending for the Pending follow-up guard (`../_shared/session-handoff.md`), so it is re-posed at the handoff rather than lost.** **Under an autonomous run (`auto`) this is a process confirm gate**, so it follows the advisory boundary in `references/auto.md`: `auto` proceeds (takes the delegate option) without stopping to brief.
48
49
  4. Resolve it with its type's engine in **`references/ticket-engines.md`**, **zoom as needed**: fetch the full body of related/closed tickets on demand. If in doubt on a `decision` ticket, run **Grilling** + **Domain vocabulary**. If in doubt on a `build`, **stop and ask** (see the Discuss step): never auto-grill.
49
50
  5. Record the resolution: a comment with the answer, `gh issue close`, add the pointer to *Decisions so far*. Then **unblock dependents**: for every ticket this one was blocking, if it was the last open blocker, remove its `trailhead:blocked` label so it graduates onto the frontier.
@@ -59,7 +60,7 @@ It runs the **full engine** (the `build`/`bug` cycle in `references/ticket-engin
59
60
  - **Grill only if needed** (Discuss): never auto-grill; start from Plan and stop to ask only if a blocking ambiguity surfaces, exactly as the `build` Discuss. The user may ask to grill at any time.
60
61
  - **Never splits.** If the ticket turns out large, work it whole anyway: that is the point of `quick`. Do not `split` it and do not spin children. (New scope that surfaces mid-work is still captured out, never folded in, see **Scope that surfaces while working a ticket**, served by the capture cluster.)
61
62
  - **No map narrative book-keeping.** A whiteboard ticket has no map, so there is no `Decisions so far` update, no fog to graduate, no frontier re-scan. Everything else holds: atomic commits with `Refs: #<n>`, TDD / Code review / Acceptance testing per the cycle, the resolution comment + `gh issue close`, and the **Session handoff** (`../_shared/session-handoff.md`). **Exception, `quick <n>` on a *map* ticket: dependent-unblocking still runs** (remove `trailhead:blocked` from any dependent whose last blocker just closed, **Work the map** step 5 above); skipping it would strand those dependents off the frontier for good. Only the *narrative* book-keeping above and the split are skipped, so prefer `work <n>` for a map ticket that has dependents or belongs in the map record.
62
- - **Same isolation setup as `work`.** `quick` **writes the `.trailhead/session-ticket` marker at the working root in every isolation mode** (exactly as **Work the map** step 2, so tooling like the statusline shows what's in play; no active-map marker, though: a whiteboard ticket has no map). Under `auto` it runs the same work-start concurrency ask **before any edit** and resolves to current-checkout / worktree / clone by the same rule; under an explicit `isolation: worktree`/`clone` it sets up the ticket's `trailhead/t<n>` workspace before any edit (including the submodule-forces-`clone` escalation and submodule provisioning) and re-checks the claim; under `isolation: none` (or `auto` answered no), the scope-free check applies too. `quick` also runs the same commit-msg hook sync at the working root, exactly as **Work the map** step 2.
63
+ - **Same isolation setup as `work`.** `quick` **writes the `.trailhead/session-ticket` marker at the working root in every isolation mode** (exactly as **Work the map** step 2, so tooling like the statusline shows what's in play; the active-map marker follows the same rule as `work`: a named ticket's map becomes the active map, a whiteboard ticket leaves it alone, see **Active map** in `../_shared/multi-map.md`). Under `auto` it runs the same work-start concurrency ask **before any edit** and resolves to current-checkout / worktree / clone by the same rule; under an explicit `isolation: worktree`/`clone` it sets up the ticket's `trailhead/t<n>` workspace before any edit (including the submodule-forces-`clone` escalation and submodule provisioning) and re-checks the claim; under `isolation: none` (or `auto` answered no), the scope-free check applies too. `quick` also runs the same commit-msg hook sync at the working root, exactly as **Work the map** step 2. It also writes the work state last, exactly as **Work the map** step 2, with `--map none` for a whiteboard ticket.
63
64
  - **Refreshes the dashboard at the handoff, not mid-work.** A `quick "<text>"` whiteboard ticket is born and resolved in the **same** session, so its dashboard refresh rides with the session's end, not with ticket creation: **do not refresh right after the ticket exists** (that lands a structural pinned-issue write ahead of the work `quick` exists to just-get-done, and only duplicates the resolution refresh a moment later). Refresh at **Resolve** (the handoff), as a **full regeneration from the live tracker** (never a partial patch that leaves other sections knowingly stale), so the whiteboard ticket lands on the dashboard as the session closes. **If the session pauses before Resolve** (`/trailhead:pause`), refresh there instead, so a deliberately-unfinished quick still shows (the read-only renders never rewrite the dashboard body, so nothing else would surface it). `quick <n>` on a *map* ticket does **not** refresh (map ticket progress shows via the native bar). See **The dashboard** and its freshness rule in `../_shared/substrate.md`.
64
65
  - **Always size-triages, independent of `config.effort`.** `quick` is the home of the cost-aware path: reaching for `quick` is itself the signal the piece is small, so it runs the **same size triage and ask-to-skip offer defined canonically under effort in `../_shared/configuration-reference.md`** (the same one the `build`/`bug` engine runs, `references/ticket-engines.md`) **every time, at the start before planning, whatever `config.effort` is set to**. `quick` does not wait for `effort: lean`, and an explicit `effort: standard` does not suppress it: `effort` governs `work` / the map cycle, not `quick`. On a trivial/small ticket it surfaces the one consolidated offer (the plan pass, TDD, cross-AI review, the full code review, the goal-backward verify) and goes lean **only on an explicit yes**; on a large ticket there is no offer and the full cycle runs. The correctness spine (atomic `Refs:` commits, tests/criterion, post-commit self-check, Resolve) is never in the offer, whatever the size or the answer.
65
66
 
@@ -53,4 +53,6 @@ Whatever ends the run (frontier drained or human interrupt), auto emits **one ru
53
53
  - **what resolved** (the tickets auto closed this run);
54
54
  - **what remains**: the **set-aside** tickets (fog, `decision` convergences, out-of-scope rulings, HITL `task` checklists, human-only UAT steps, and rail actions including any **ready-to-integrate branch / PR** under `git: pr`) and **why each needs the human**, the **paused / stuck** tickets from the failure budget, and any **blocked dependents** still waiting.
55
55
 
56
+ Before handing back, if a work state file remains (an interrupted or paused ticket), run `node <templates>/trailhead-work-state.js set --repo <working-root> --auto false`. During the run every work-state `set` adds `--auto true --auto-done <n>`, `n` being the tickets resolved so far in this run (`autoDone` in the file).
57
+
56
58
  Then hand back to the human with the normal session handoff framing (`../../_shared/session-handoff.md`): the human decides what to take next (dissolve fog, make a held decision, authorise a rail action, then re-invoke `/trailhead:auto` to drain more).