@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -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
 
@@ -70,13 +72,15 @@ Worked examples: 2 real options + delegate + defer = 4 -> all in the menu; 3 rea
70
72
  | `/trailhead config` menus | Yes | No (no substrate) |
71
73
  | Cross-AI plan-review convergence | Yes | Sometimes (a concern blocked on a prerequisite) |
72
74
  | Verify checkpoint (which fix / how to proceed) | Yes for the advisory sub-choice; accept/resolve stays the human's | No |
73
- | Acceptance testing drive-mode ask (AI-driven vs human-driven) | Yes | No (now-or-never at UAT start) |
75
+ | Acceptance testing drive-mode ask (AI-driven, human-driven, or skip) | Yes, but delegate never picks skip (waiving acceptance is the human's call; on the reduced ask with no agent-performable step it resolves to human-driven) | No (now-or-never at UAT start) |
74
76
  | Prototype "want mockups?" | Yes | No |
75
77
  | Teamwork split shape / submodule split | Yes (the shape is advisory) | No |
76
78
  | Claim collision | No (coordination stop) | No |
77
- | `auto` isolation work-start ask (concurrency) | Yes (an autonomous run takes it as the current checkout without asking) | No (now-or-never at work-start) |
79
+ | `auto` isolation work-start ask (concurrency) | Yes (an autonomous run takes it as the current checkout without asking; a remembered answer is reused without asking) | No (now-or-never at work-start) |
78
80
  | Exhaustion "close the map?" | No (record the human owns) | No |
79
81
  | Out-of-scope routing (deferred line → seed/idea/todo) | No (routing the human owns) | This IS a defer already |
82
+ | Ticket-start scope confirm (`work`/`quick` brief) | Yes (a process confirm; `auto` takes it); skipped per **Skip the confirm on a ticket this session already shaped** in `../trailhead-work/SKILL.md` | No (now-or-never at ticket start) |
83
+ | Divergent change at the brief (as-is vs separate ticket) | No (routing the human owns) | No (a deferral is parked by the Pending follow-up guard) |
80
84
 
81
85
  The one-line test above generalises to any call-site not listed: if the answer is itself a project decision (a `decision`-ticket answer, or a destination/scope change) there is no delegate; a process choice that merely gets mentioned in a later resolution comment is still advisory.
82
86
 
@@ -18,8 +18,18 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
18
18
  | `plan_review.rounds` | integer (**`2`**) | max converge-and-re-review rounds |
19
19
  | `code_review.rounds` | integer (**`2`**) | max fix-and-re-review rounds in **Code review** before the boundary HITL checkpoint |
20
20
  | `effort` | **`standard`** \| `lean` | how hard the `build`/`bug` engine works a change: `standard` runs every step; `lean` size-triages and offers to skip heavy steps (plan pass, TDD, cross-AI review, full code review, goal-backward verify) on small changes. **Governs `build`/`bug` only; `quick` always size-triages regardless of this key** (see effort below) |
21
+ | `isolation.copy` | array of repo-relative paths (**unset**) | gitignored files and directories copied from the main checkout into a new `worktree`/`clone` workspace, see **Isolation copy** |
22
+ | `preflight.checks` | array of shell commands (**unset**) | project checks run by the stale-base preflight before UAT and integration, see **Preflight checks** |
23
+ | `uat.setup` | array of steps `{name, run, check?, note?}` (**unset**) | ordered setup the agent runs before the drive-mode ask at every UAT start, see **UAT setup** |
24
+ | `uat.accounts` | object role -> `{user, secret}` (**unset**) | named test accounts for UAT; `secret` is always a `$ENV` reference, see **UAT setup** |
21
25
 
22
- **`git`, `release`, and `isolation` are not config.json keys.** They are **conventions-header keys**, stored in the repo's `trailhead:conventions` issue header (documented in `substrate.md`), and are reachable from the guided setup's **Way of working** step, not from this table.
26
+ **`git`, `release`, and the isolation mode are not config.json keys** (the one exception is `isolation.copy`, which lives in config.json; the `isolation:` mode itself stays in the conventions header). They are **conventions-header keys**, stored in the repo's `trailhead:conventions` issue header (documented in `substrate.md`), and are reachable from the guided setup's **Way of working** step, not from this table.
27
+
28
+ **Isolation copy.** `isolation.copy` lists the gitignored files a new `worktree`/`clone` workspace needs to run (`.env`, `.npmrc`, local config), copied from the main checkout right after the workspace is created by `trailhead-isolation-copy.js --from <main-checkout> --to <working-root>`. Write it nested (`{"isolation": {"copy": [".env"]}}`) or flat (`"isolation.copy": [".env"]`); if one file carries both, the nested form wins and a warning says so. The project value replaces the global one as a whole (no list merge, and even `[]` replaces); the global list applies only when the project file has no `isolation.copy`. Entries are plain repo-relative paths (a file or a directory), no globs. An existing destination is never overwritten (reported `present`). Unsafe entries are rejected: absolute paths, `..` segments, any `.git` segment, and any path whose parent directory resolves outside its root (source or destination, so an intermediate symlink cannot redirect a read or write); a final symlink is copied as a symlink, never followed, and nested `.git` entries are skipped inside copied directories. When the key is unset, tracked `*.example` / `*.sample` files whose stripped sibling (`.env.example` -> `.env`) exists and is gitignored in the main checkout are offered as suggestions, never copied unasked. An invalid value (bad JSON, a non-array, a non-string or empty entry) copies nothing, offers nothing, and is reported.
29
+
30
+ **Preflight checks.** `preflight.checks` lists shell commands the stale-base preflight (`trailhead-base-preflight.js`, **Stale-base preflight** in `teamwork.md`) runs at the working root, after any sync, before UAT and before integration. A non-zero exit fails the check and stops the ticket there. Write it nested (`{"preflight": {"checks": ["..."]}}`) or flat (`"preflight.checks": ["..."]`); if one file carries both, the nested form wins and a warning says so. The project value replaces the global one as a whole value. Use it for rules a textual merge cannot see, for example unique migration timestamps: `"find supabase/migrations -name '*.sql' | sed 's#.*/##; s#_.*##' | sort | uniq -d | (! grep .)"` fails when two migrations share a prefix. Each command has a timeout; an invalid declaration (not an array, or an empty entry) runs nothing and is reported as `checksError`.
31
+
32
+ **UAT setup.** `uat.setup` is an ordered list of steps `{ "name", "run", "check"?, "note"? }` that `trailhead-uat-setup.js` runs at every UAT start, after the stale-base preflight and before the drive-mode ask (see **UAT setup comes next** in `techniques/acceptance-testing.md`). `run` is a shell command run at the working root; `check`, when present, is a shell command that exits 0 when the step is already up: a passing `check` skips the step, and after `run` the helper polls `check` until it passes (or times out), so a dev server backgrounded inside `run` is detected through it; with no `check` the step always runs. `note` is free text the agent honours before running that step (for example "ask before using the Pixel"), the home for project-specific rules. `"uat": { "setup": [] }` means "no setup needed": no probe and no offer. `uat.accounts` holds named test accounts, `{ "<role>": { "user": "...", "secret": "$ENV_VAR" } }`; the agent picks the account matching the UAT step and asks when ambiguous. **References, never values:** `secret` must be a bare `$NAME` or `${NAME}` reference (a literal is rejected), and every machine-specific value (device id, local URL, a personal `user`) is a `$ENV` reference too, so the committed `.trailhead/config.json` stays generic and works unchanged in isolation workspaces; a shared QA `user` may be a literal, a personal or machine-specific one must be a reference (a documented rule, not checked). A step whose referenced variable is unset or empty fails as `missing-env` naming the variable, and nothing runs. A `$NAME` / `${NAME}` outside single quotes in `run` / `check` is such a reference and must be an exported env variable; for a shell-local one (a loop variable, an `awk` field) use single quotes or escape it as `\$NAME`. A single quote inside double quotes (for example inside `"$(awk '{print $NF}')"`) does not exempt a reference; put such a command in a script file and call the script from `run`. A long-lived process backgrounded in `run` must redirect its own output (for example `> /tmp/dev.log 2>&1 &`), else it keeps writing to the helper's deleted capture file. Write it nested (`{"uat": {"setup": [...]}}`) or flat (`"uat.setup": [...]`); if one file carries both, the nested form wins and a warning says so. The project value replaces the global one key by key as a whole value. `testing.webapp` / `testing.url` are unchanged: setup brings the target up, `testing.url` says where to drive it.
23
33
 
24
34
  **Models.** Each key targets a specific activity, so you can send the heavy reasoning to a strong model and the cheap legwork to a fast one:
25
35
  - `plan` / `execute`: the `build`/`bug` engine's **Plan** and **Execute** steps (see below).
@@ -61,7 +71,7 @@ If a value is unset, it resolves to its **Balanced**-profile default for that ac
61
71
 
62
72
  **tdd.** `seams` (default) = TDD at critical seams only; `on` = TDD for all behaviour with a definable input→output; `off` = tests after, or none for throwaway. The `build` engine's Plan/Execute honour this.
63
73
 
64
- **acceptance.browser.** The drive-mode ask (AI-driven vs human-driven, see the Acceptance testing technique) **always comes first, before any step runs**: no value of this key pre-answers it. This key only governs whether and how an AI-driven step uses the browser once that ask is answered AI-driven: `auto` (default) = browser-drive when `testing.webapp` + `testing.url` are set, or (when `testing.webapp` is set but `testing.url` is unset) first try to auto-detect the target URL (running dev server, package scripts, dev-server/framework config, project config) and propose it, driving against the accepted URL; when no URL can be determined, fall back per the Acceptance testing technique (ask the human for the URL, or the guided UAT for that step); `on` = always attempt browser-driving, auto-detecting the URL the same way when `testing.url` is unset; `off` = never drive a browser. **`off` disables *browser-driving*, not acceptance, and not AI-driven checking**: AI-driven still runs its non-browser checks via commands / the test suite for whatever steps allow it; only a step that genuinely needs the human (or the whole UAT, under human-driven) gets the guided UAT walkthrough. The Verify step honours this.
74
+ **acceptance.browser.** The drive-mode ask (AI-driven, human-driven, or skip, see the Acceptance testing technique) **always comes first, before any step runs**: no value of this key pre-answers it, and none implies a waiver. This key only governs whether and how an AI-driven step uses the browser once that ask is answered AI-driven: `auto` (default) = browser-drive when `testing.webapp` + `testing.url` are set, or (when `testing.webapp` is set but `testing.url` is unset) first try to auto-detect the target URL (running dev server, package scripts, dev-server/framework config, project config) and propose it, driving against the accepted URL; when no URL can be determined, fall back per the Acceptance testing technique (ask the human for the URL, or the guided UAT for that step); `on` = always attempt browser-driving, auto-detecting the URL the same way when `testing.url` is unset; `off` = never drive a browser. **`off` disables *browser-driving*, not acceptance, and not AI-driven checking**: AI-driven still runs its non-browser checks via commands / the test suite for whatever steps allow it; only a step that genuinely needs the human (or the whole UAT, under human-driven) gets the guided UAT walkthrough. The Verify step honours this.
65
75
 
66
76
  **design.surface / design.system.** Under `design: stitch`, `design.surface` picks the surface: `canvas` (default) = a Stitch project with generated screens, `design-system` = a Stitch design system; the install offer and disk fallback are in Prototype (the Stitch MCP either connects or falls to disk). Under `design: artifacts` there is one surface, a **Design canvas Artifact** (`design.project` = its URL), and `design.surface` is ignored; `design.system` names the **Design System Artifact** installed on that canvas, a read-only reference: screens are never pushed into it. **Back-compat:** a `design.project` left from the standalone claude.ai/design (a bare project id or a `claude.ai/design/p/<id>` URL) is not reusable; Prototype says so once and attaches a new canvas. Ignored under `design: disk`.
67
77
 
@@ -98,7 +108,7 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
98
108
  - **🔀 Git**: 🌱 `main` (commit straight to the trunk) · 🌿 `pr` (feature branch + PR).
99
109
  - **🚀 Release**: 🎯 `command` (release only on an explicit command) · ♻️ `auto` (release automatically per the project's flow).
100
110
  - **🧬 Isolation**: 🤖 `auto` (default; decide per ticket at work-start) · 📍 `none` (work in the current checkout) · 🌳 `worktree` (a `git worktree` + `trailhead/t<n>` branch per ticket) · 📦 `clone` (a dedicated per-ticket clone, for path-bound apps a worktree can't build and for submodule superrepos).
101
- Write any changed value back into the `trailhead:conventions` issue header (a body read-modify-write: obey the **Fail-safe body rewrites** rule in `substrate.md`), leaving the human prose below the header untouched. **If no `trailhead:conventions` issue exists yet** (a repo not charted/adopted): say conventions are normally set at `/trailhead:new`/`:adopt`, and offer to create a minimal header issue with the chosen values, or skip. Creating it here first runs the **first-use repo setup** (see `substrate-commands.md`) so the `trailhead:conventions` label exists before `gh issue create --label trailhead:conventions` (an uncharted repo has no `trailhead:*` labels yet, so the create would otherwise fail).
111
+ Write any changed value back into the `trailhead:conventions` issue header (a body read-modify-write: obey the **Fail-safe body rewrites** rule in `substrate.md`; default path `node <templates>/trailhead-issue-body.js replace --issue <conventions> --section :preamble --file <new header>` (the fenced header block, fences included), `<templates>` being the same three-dir lookup as `trailhead-work-state.js`, see **Work state file** in `teamwork.md`; by hand when the helper is missing), leaving the human prose below the header untouched. **If no `trailhead:conventions` issue exists yet** (a repo not charted/adopted): say conventions are normally set at `/trailhead:new`/`:adopt`, and offer to create a minimal header issue with the chosen values, or skip. Creating it here first runs the **first-use repo setup** (see `substrate-commands.md`) so the `trailhead:conventions` label exists before `gh issue create --label trailhead:conventions` (an uncharted repo has no `trailhead:*` labels yet, so the create would otherwise fail).
102
112
  3. **🌐 Ticket language**: the language trailhead writes its GitHub prose & commit descriptions in (`ticket.language`, ISO 639-1). Offer 🇬🇧 English (`en`, default) · 🇮🇹 Italiano (`it`) · 🇪🇸 Español (`es`) · 🇫🇷 Français (`fr`) · 🇩🇪 Deutsch (`de`) · 🇵🇹 Português (`pt`), plus a free-code option for any other ISO 639-1 code. Note in the prompt that this is *not* the language the agent converses in.
103
113
  4. **🧠 Models**: a **profile** (Balanced by default) applied whole, or **Manual** for the per-key questions.
104
114
  - **🎚️ Profile, asked first (and often the only Models question).** Present one `AskUserQuestion` menu: 🏔️ **High** · ⚖️ **Balanced (consigliato)** · 🍃 **Low** · ✍️ **Manual (specify each model)**. **On a project with no existing config, pre-select ⚖️ Balanced** (the default profile); when a config already exists, pre-select the profile its keys match (or **Manual** when they match none). **On High / Balanced / Low this is terminal:** seed the seven model keys from the **Model-tier profiles** table above, resolving each tier-class to a concrete id **at setup time** (verify the lineup, don't trust ids verbatim), **write them, and do NOT ask the per-key questions**. Only **Manual** proceeds to the per-key questions below (where any key may be left at `inherit session`). **Host branch:** on **Claude** the profile seeds `models.*` with tier-class ids; on **Codex** it seeds `models.codex.*` as `{model, effort}` (effort is the Codex-only face), and after applying keep the **re-run the installer** instruction from the Codex per-key path below so the pins are re-projected into `~/.codex/agents/trailhead-*.toml`.
@@ -109,6 +119,8 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
109
119
  6. **🧪 TDD**: 🎯 Seams only · ✅ On (all definable behaviour) · ⛔ Off.
110
120
  7. **⚖️ Effort**: how hard the `build`/`bug` engine works each change (`effort`). 🛠️ **Standard** (default; every step runs) · 🍃 **Lean** (size-triage small changes and offer to skip heavy steps, on an explicit yes only: the plan pass, TDD, cross-AI review, the full code review, the goal-backward verify; correctness checks always stay). Governs `build`/`bug` only: `quick` always size-triages regardless.
111
121
  8. **🖥️ Acceptance testing**: 🤖 Auto · ▶️ Always browser · 🚫 No browser (acceptance still runs, starting with the drive-mode ask; only browser-driving is off, see `acceptance.browser` above). If browser is enabled, ask **🔗 base URL** and confirm it's a web app.
122
+ - **🧰 UAT setup** (sub-step): ask whether the project needs setup before a UAT: ⏭️ Keep current (when `uat.setup` is already set) · 🔎 Detect now and propose (probe package scripts, Expo/adb, docker compose, Supabase functions/migrations, an already-running server; show the plan, save on yes with `$ENV` references) · 🚫 None needed (writes `uat.setup: []`) · ⏳ Decide at the first UAT (leaves it unset).
123
+ - **👤 Test account** (optional sub-step): offer to add an account to `uat.accounts`; ask the role, the user, and the **name of the env var** holding the secret, never the secret's value.
112
124
  9. **🧑‍⚖️ Plan review**: **before presenting this menu, first probe the PATH for known external AI CLIs** (probe **every** CLI in the Cross-AI plan review roster, do not shorten the list: `claude`, `gemini`, `codex`, `qwen`, `kimi`, `agy`, `opencode`, `cursor-agent`, `coderabbit`, plus any OpenAI-compatible local server configured; `kimi` counts only when `kimi --help` mentions `--output-format`, per the technique): `command -v` each one, then build the menu from what was actually found. **Exclude the current host's own CLI** (never offer `codex` when running on Codex, nor `claude` when running on Claude Code): a session reviewing a plan with its own model is not cross-AI, so the host's own CLI must never appear as an option. Never present this step with a hard-coded two-option list; the options are computed from the probe:
113
125
  - **No CLI found**: offer only ⛔ Off, and say in the prompt that no external AI CLI was detected on the PATH (so the other choices would do nothing).
114
126
  - **One CLI found**: ⛔ Off · 🤝 Use `<name>`. (With a single CLI, "all" and "pick specific" collapse to the same thing, so don't show both.)
@@ -2,7 +2,7 @@
2
2
 
3
3
  The **shared-core load contract**, single-sourced here and referenced by every cluster `SKILL.md`: the dispatcher `trailhead/` and the five clusters `trailhead-chart` / `trailhead-work` / `trailhead-view` / `trailhead-capture` / `trailhead-manage`. Each cluster's own `## Load first, in order` points here in a line, then states its cluster-specific isolation posture; this file holds the part that is identical for all of them.
4
4
 
5
- The paths below are written **relative to the cluster `SKILL.md` that pointed you here** (exactly as that `SKILL.md`'s own shared-core references are), so `../_shared/` resolves to this directory from any cluster.
5
+ Every path below is a locator, spelled the way the cluster `SKILL.md` that pointed you here spells it (so `../_shared/` is this directory). Do not read it relative: resolve it against that cluster's base directory into an **absolute path** under the `_shared/` directory this file was loaded from, and read each of the six core files with the **Read tool** on that absolute path (on a host without a Read tool, a single `cat <absolute path>` with no `cd`). Never `cd` into a skills directory and then read a relative path. The same goes for `<lib>` and `<templates>` below: pass absolute paths to `node`. Every other `../_shared/...` or `references/...` path the engine names (in any cluster doc, reference, or technique) is resolved the same way, to an absolute path, before reading.
6
6
 
7
7
  Before doing anything, read the shared core, in order:
8
8
 
@@ -17,6 +17,6 @@ Before doing anything, read the shared core, in order:
17
17
 
18
18
  Load the **effective config** (per `../_shared/configuration.md`) at session start, from the map's project root (`.trailhead/config.json` plus the global file), before any isolation workspace is set up.
19
19
 
20
- **Pinned model review, once per session.** After loading the effective config, run the read-only check: `node <lib> check <project root>`, where `<lib>` is the first of these two paths (relative to this `_shared/` dir) that exists: `../../hooks/lib/model-defaults-review.js` (Claude plugin, Claude npm copy, dev-symlink install), else `../trailhead/hooks/lib/model-defaults-review.js` (Codex). If neither exists, skip silently. If it prints a notice, offer it to the user without blocking the current request; only after the user answers any of its three choices, run `node <lib> ack <project root>`. Do this at most once per session: skip it if the SessionStart hook (or an earlier config load this session) already surfaced the same offer. Repeats across sessions are expected until the user answers.
20
+ **Pinned model review, once per session.** After loading the effective config, run the read-only check: `node <lib> check <project root>`, where `<lib>` (an absolute path, resolved from this `_shared/` dir's absolute location) is the first of these two paths (relative to this `_shared/` dir) that exists: `../../hooks/lib/model-defaults-review.js` (Claude plugin, Claude npm copy, dev-symlink install), else `../trailhead/hooks/lib/model-defaults-review.js` (Codex). If neither exists, skip silently. If it prints a notice, offer it to the user without blocking the current request; only after the user answers any of its three choices, run `node <lib> ack <project root>`. Do this at most once per session: skip it if the SessionStart hook (or an earlier config load this session) already surfaced the same offer. Repeats across sessions are expected until the user answers.
21
21
 
22
- **Label guard check, once per session.** Same pattern for the repo's label guard: run `node <templates>/trailhead-label-guard-sync.js --repo <project root> --notice`, with `<templates>` resolved as for the commit-msg sync script (see `teamwork.md`, Trust & provenance → Repo-side enforcement; skip silently if none of the template dirs has the script). It prints an offer only when the committed guard is the outdated one (no job-level `if:`, so every label event bills a runner) and the user has not answered for that file yet. Offer it without blocking the current request and follow its instructions; only after the user picks either choice, run the `--ack` command it names. At most once per session: skip it if the SessionStart hook already surfaced it.
22
+ **Label guard check, once per session.** Same pattern for the repo's label guard: run `node <templates>/trailhead-label-guard-sync.js --repo <project root> --notice`, with `<templates>` (an absolute path) resolved as for the commit-msg sync script (see `teamwork.md`, Trust & provenance → Repo-side enforcement; skip silently if none of the template dirs has the script). It prints an offer only when the committed guard is the outdated one (no job-level `if:`, so every label event bills a runner) and the user has not answered for that file yet. Offer it without blocking the current request and follow its instructions; only after the user picks either choice, run the `--ack` command it names. At most once per session: skip it if the SessionStart hook already surfaced it.
@@ -10,12 +10,12 @@ Both are set at chart and on every ticket **in the same pass** (create the ticke
10
10
  **Scoped frontier** (one query, per map):
11
11
  ```bash
12
12
  gh issue list --label "trailhead:ticket" --label "trailhead:map-<n>" --state open \
13
- --search "no:assignee -label:trailhead:blocked -label:trailhead:unverified"
13
+ --search "no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused"
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
- **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.
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` (a paused ticket is off the frontier, so bare `/trailhead:work` never picks it up).
19
19
 
20
20
  **Discovery.** `gh issue list --label "trailhead:map" --state open` enumerates the live maps. `/trailhead:map` with **no active map and more than one open** lists them and asks which; `/trailhead:map <n>` shows that one and makes it active.
21
21
 
@@ -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**: one call, `node <templates>/trailhead-work-state.js clear --repo <working-root>` (see **Work state file** in `teamwork.md`), removes both. When the helper is missing, delete `.trailhead/session-ticket` and `.trailhead/work-state.json` by hand. 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`).