@marcomigozzi/trailhead 0.7.0 → 0.8.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 (40) hide show
  1. package/README.it.md +10 -6
  2. package/README.md +10 -6
  3. package/bin/lib/codex-projection.js +9 -6
  4. package/bin/lib/codex-projection.test.js +5 -1
  5. package/bin/trailhead.js +50 -5
  6. package/bin/trailhead.test.js +55 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/agents/trailhead-code-review.md +2 -0
  10. package/plugins/trailhead/agents/trailhead-codebase-map.md +2 -0
  11. package/plugins/trailhead/agents/trailhead-debug.md +2 -0
  12. package/plugins/trailhead/agents/trailhead-executor.md +2 -0
  13. package/plugins/trailhead/agents/trailhead-fix.md +2 -0
  14. package/plugins/trailhead/agents/trailhead-plan.md +2 -0
  15. package/plugins/trailhead/agents/trailhead-research.md +2 -0
  16. package/plugins/trailhead/agents/trailhead-verify.md +2 -0
  17. package/plugins/trailhead/commands/auto.md +6 -0
  18. package/plugins/trailhead/commands/quick.md +1 -1
  19. package/plugins/trailhead/hooks/hooks.json +13 -0
  20. package/plugins/trailhead/hooks/lib/shell-scan.js +247 -0
  21. package/plugins/trailhead/hooks/lib/shell-scan.test.js +49 -0
  22. package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
  23. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
  24. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
  25. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
  26. package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -0
  27. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  28. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  29. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  31. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
  32. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  33. package/plugins/trailhead/skills/trailhead/SKILL.md +4 -2
  34. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -0
  35. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
  36. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -0
  37. package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -1
  38. package/plugins/trailhead/skills/trailhead-work/SKILL.md +19 -7
  39. package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
  40. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +2 -2
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-search-guard.js. Run: node trailhead-search-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+
8
+ const HOOK = path.join(__dirname, 'trailhead-search-guard.js');
9
+
10
+ let passed = 0;
11
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
12
+
13
+ // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
14
+ function runHook(command) {
15
+ const input = JSON.stringify({ tool_input: { command } });
16
+ try {
17
+ const out = execFileSync('node', [HOOK], { input });
18
+ return { code: 0, out: out.toString() };
19
+ } catch (e) {
20
+ return { code: e.status, out: (e.stdout || '').toString(), err: (e.stderr || '').toString() };
21
+ }
22
+ }
23
+
24
+ // --- ticket #134: the exact regression command must be BLOCKED ---
25
+ const ticketCmd = String.raw`cd app && grep -n "go_router" pubspec.yaml pubspec.lock | head; echo "=== router file ==="; grep -rln "GoRouter(" lib/ ; echo "=== /who route def ==="; grep -rn "'/who'\|\"/who\"\|/profile/manage\|path: '/home'" lib/ | head -30`;
26
+ const rTicket = runHook(ticketCmd);
27
+ ok('ticket #134 exact command is blocked (exit 2)', rTicket.code === 2 && /SEARCH_HYGIENE_VIOLATION|search-hygiene/.test(rTicket.out));
28
+
29
+ // --- BLOCK cases ---
30
+ const r1 = runHook('cd app && grep -n "go_router" pubspec.yaml');
31
+ ok('cd <relative> && grep <relative> is blocked (exit 2)', r1.code === 2 && /search-hygiene/.test(r1.out));
32
+
33
+ const r2 = runHook('cd /abs/app; grep -rln "GoRouter(" lib/');
34
+ ok('cd <abs>; grep <relative> is blocked (exit 2)', r2.code === 2 && /search-hygiene/.test(r2.out));
35
+
36
+ const r3 = runHook('pushd app && cat file.txt');
37
+ ok('pushd <dir> && cat <relative> is blocked (exit 2)', r3.code === 2 && /search-hygiene/.test(r3.out));
38
+
39
+ const r4 = runHook('cd app && wc -l foo.txt');
40
+ ok('cd <dir> && wc -l <relative> is blocked (exit 2)', r4.code === 2 && /search-hygiene/.test(r4.out));
41
+
42
+ const r5 = runHook('cd app &&\ngrep -n "x" foo.txt\n');
43
+ ok('cd <dir> && then relative grep on a later line (newline-joined) is blocked (exit 2)', r5.code === 2 && /search-hygiene/.test(r5.out));
44
+
45
+ const r6 = runHook('cd app; echo hi; grep -n "x" foo.txt');
46
+ ok('cd <dir>; ...; relative grep several statements later is blocked (exit 2)', r6.code === 2 && /search-hygiene/.test(r6.out));
47
+
48
+ const r7 = runHook('env -C app grep -n "x" foo.txt');
49
+ ok('env -C <dir> grep <relative> (single statement) is blocked (exit 2)', r7.code === 2 && /search-hygiene/.test(r7.out));
50
+
51
+ const r8 = runHook('cd app && git log -- foo.txt');
52
+ ok('cd <dir> && git log without -C on a relative path is blocked (exit 2)', r8.code === 2 && /search-hygiene/.test(r8.out));
53
+
54
+ const r9 = runHook('cd app && rg "TODO" foo.txt');
55
+ ok('cd <dir> && rg <relative> is blocked (exit 2)', r9.code === 2 && /search-hygiene/.test(r9.out));
56
+
57
+ // --- ALLOW cases (no false positives) ---
58
+ const a1 = runHook('grep -n "x" /abs/app/pubspec.yaml');
59
+ ok('path-explicit grep with no cd is allowed (exit 0)', a1.code === 0 && a1.out.trim() === '');
60
+
61
+ const a2 = runHook('git -C /abs/app grep "x"');
62
+ ok('git -C <abs> grep is allowed (exit 0)', a2.code === 0 && a2.out.trim() === '');
63
+
64
+ const a3 = runHook('cd /abs && npm test');
65
+ ok('cd <abs> && npm test (no read/search verb) is allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
66
+
67
+ const a4 = runHook('cd /abs && ls -la');
68
+ ok('cd <abs> && ls (not a Read()-denied verb) is allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
69
+
70
+ const a5 = runHook('cd /abs/app');
71
+ ok('a bare standalone cd <abs> alone is allowed (exit 0) - known limitation, never block a lone cd', a5.code === 0 && a5.out.trim() === '');
72
+
73
+ const a6 = runHook('grep "cd foo" /abs/file');
74
+ ok('grep whose pattern happens to contain "cd" but path is absolute is allowed (exit 0)', a6.code === 0 && a6.out.trim() === '');
75
+
76
+ const a7 = runHook('grep -n "x" file --with-something');
77
+ ok('command with no cd at all is allowed regardless of relative path (exit 0)', a7.code === 0 && a7.out.trim() === '');
78
+
79
+ const a8 = runHook('cd /abs/app && grep -n "x" /abs/app/foo.txt');
80
+ ok('cd <abs> && grep with an absolute-path argument is allowed (exit 0)', a8.code === 0 && a8.out.trim() === '');
81
+
82
+ const a9 = runHook('cd /abs/app && grep -n "x" -r --include=*.js');
83
+ ok('cd <abs> && grep with only flags/patterns (no file arg) is allowed (exit 0)', a9.code === 0 && a9.out.trim() === '');
84
+
85
+ // --- #134 follow-up: Finding 1 - heredoc bodies are DATA, not statements ---
86
+ const h1 = runHook(`cat > setup.sh <<'EOF'\ncd /some/dir\ngrep -n pattern file.txt\nEOF\n`);
87
+ ok('heredoc body (cd + grep as literal data) is allowed, not parsed as statements (exit 0)', h1.code === 0 && h1.out.trim() === '');
88
+
89
+ const h2 = runHook(`bash -c 'cat' <<EOF\ncd app\ngrep -n x foo.txt\nEOF\n`);
90
+ ok('heredoc fed to a command is allowed, its body is data not executed statements (exit 0)', h2.code === 0 && h2.out.trim() === '');
91
+
92
+ const h3 = runHook(`cat > setup.sh <<'EOF'\nsome literal data\nEOF\ncd app && grep -n x foo.txt`);
93
+ ok('a REAL cd + grep violation outside a heredoc on the same command still blocks (exit 2)', h3.code === 2 && /search-hygiene/.test(h3.out));
94
+
95
+ // --- #134 follow-up: Finding 2 - grouping containers must not hide the violation ---
96
+ const g1 = runHook('(cd app && grep -n x foo.txt)');
97
+ ok('subshell-wrapped cd + grep is blocked (exit 2)', g1.code === 2 && /search-hygiene/.test(g1.out));
98
+
99
+ const g2 = runHook('{ cd app; grep -n x foo.txt; }');
100
+ ok('brace-group-wrapped cd + grep is blocked (exit 2)', g2.code === 2 && /search-hygiene/.test(g2.out));
101
+
102
+ const g3 = runHook('for d in */; do cd "$d" && grep -n x foo.txt; cd ..; done');
103
+ ok('loop-body cd + grep is blocked (exit 2)', g3.code === 2 && /search-hygiene/.test(g3.out));
104
+
105
+ const g4 = runHook('(cd /abs && npm test)');
106
+ ok('subshell-wrapped cd <abs> && npm test (no read verb) is still allowed (exit 0)', g4.code === 0 && g4.out.trim() === '');
107
+
108
+ // --- #134 follow-up: Finding 3 - `--` end-of-options marker must not swallow the file ---
109
+ const d1 = runHook('cd app && grep -- -x foo.txt');
110
+ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the file arg', d1.code === 2 && /search-hygiene/.test(d1.out) && /foo\.txt/.test(d1.out));
111
+
112
+ // --- #134 follow-up: Finding 4 - rg-specific value flags must not misattribute the file ---
113
+ const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
114
+ ok('cd + rg -g <glob> <pattern> <relative file> reports the real file, not the pattern (exit 2)', f1.code === 2 && /search-hygiene/.test(f1.out) && /src\/foo\.js/.test(f1.out) && !/`TODO`/.test(f1.out));
115
+
116
+ console.log(`✓ search-guard: ${passed} assertions passed`);
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-secret-read-guard.js: PreToolUse(Read|Bash) hook.
3
+ // Restores the `.env`/`.secrets` read protection that three `Read()` deny
4
+ // permission rules used to provide, before they were removed (#135). A
5
+ // `Read()` deny rule is a PERMISSION rule: under bypass permissions it still
6
+ // falls back to a manual approval prompt on Claude Code 2.1.259's new
7
+ // `cd-compound-read` check (the same headless-breaking prompt that
8
+ // trailhead-search-guard.js exists to route around, see #132-#134). This hook
9
+ // gets the same outcome — a secret file can never be read — as a HOOK `deny`
10
+ // decision instead, which does not arm that permission-layer prompt at all.
11
+ //
12
+ // Denies:
13
+ // (a) a Read tool call whose `file_path` basename is a secret pattern; and
14
+ // (b) a Bash command that touches a secret path as a plain positional file
15
+ // operand, an option value (`--file=.env`, `-f .env`, glued `-f.env`),
16
+ // or a `<` (or glued `<file`) redirect target.
17
+ // A "secret pattern" is a basename that is exactly `.env`, exactly
18
+ // `.secrets`, or matches `.env.<anything>` (e.g. `.env.local`,
19
+ // `.env.production`), matched CASE-INSENSITIVELY (`.ENV`/`.SECRETS` are the
20
+ // same file on macOS/Windows). Matched on basename, so `app/.env` and
21
+ // `./.env.local` are caught the same as a bare `.env`.
22
+ //
23
+ // #135 follow-up (adversarial review found real false negatives/positives in
24
+ // the original naive `.split(/\s+/)` Bash tokenizer):
25
+ // - Finding 1 (glued shell metacharacters, e.g. `cat .env|grep KEY`,
26
+ // `cat .env;echo done`, `cat .env&&echo done`) is fixed by scanning
27
+ // QUOTE-AWARE, STATEMENT-BASED top-level statements (splitStatements),
28
+ // which split on operator characters regardless of surrounding
29
+ // whitespace. `bash -c "cat .env"` / `eval "cat .env"` are fixed by
30
+ // recursing into the quoted sub-command's text as a nested command.
31
+ // - Finding 2 (glued short-option value, `cat -f.env`) is fixed by also
32
+ // testing a value-flag's glued suffix (`token.slice(2)`).
33
+ // - Finding 3 (bare `.env` inside quoted prose, e.g. `git commit -m
34
+ // "document .env usage"`) is fixed by a quote-aware tokenizer that keeps
35
+ // a multi-word quoted phrase as ONE opaque token, never splitting it into
36
+ // separate words.
37
+ // - Finding 4 (a quoted grep PATTERN that happens to look like a secret
38
+ // path, e.g. `grep ".env" config.txt`) is fixed by routing search verbs
39
+ // (grep/rg/ag/sed/awk) through the shared pattern-positional-skipping
40
+ // walk, so the pattern is never mistaken for the file operand.
41
+ // - Finding 5 (case sensitivity) is fixed by lowercasing the basename
42
+ // before the pattern test.
43
+ // The quote-aware tokenizer/statement-splitter and the search-verb handling
44
+ // are shared with trailhead-search-guard.js via lib/shell-scan.js, so the two
45
+ // guards can never drift into two divergent tokenizers again.
46
+ //
47
+ // Deliberately best-effort on the Bash leg, like the sibling guards: not a
48
+ // full shell parser. It does not care about `cd` shape at all (that is
49
+ // trailhead-search-guard.js's job) — it only asks "does any token here
50
+ // resolve to a secret path", so `cd app && grep x pubspec.yaml` is allowed
51
+ // (no secret token present) regardless of the `cd`.
52
+ //
53
+ // Denies with exit 2 + {"decision":"block","code":...,"reason":...} — same
54
+ // wire shape the sibling guards use for a block, which the PreToolUse hook
55
+ // contract treats as a deny. Allows everything else. Crash-safe: any error ->
56
+ // exit 0 (never wedge the user's workflow). Self-contained (its own
57
+ // dependency is the sibling lib/ module, no external packages). Pure
58
+ // function exported for tests; runs as a hook when executed directly.
59
+
60
+ const {
61
+ basename,
62
+ stripQuotes,
63
+ splitStatements,
64
+ tokenize,
65
+ SEARCH_VERB_CONFIG,
66
+ scanArgsSkippingPattern,
67
+ } = require('./lib/shell-scan.js');
68
+
69
+ // --- path helpers ------------------------------------------------------------
70
+
71
+ // A basename is a "secret pattern" when it is exactly `.env`, exactly
72
+ // `.secrets`, or `.env.<something>` (e.g. `.env.local`, `.env.production`),
73
+ // matched case-insensitively (`.ENV`/`.Secrets` are the same file on a
74
+ // case-insensitive filesystem). Near-misses like `env.sample` (no leading
75
+ // dot) or `.environment` (no dot after `.env`) must NOT match.
76
+ function isSecretBasename(name) {
77
+ if (!name) return false;
78
+ const lower = String(name).toLowerCase();
79
+ if (lower === '.env' || lower === '.secrets') return true;
80
+ return /^\.env\..+$/.test(lower);
81
+ }
82
+
83
+ function isSecretPathArg(t) {
84
+ const bare = stripQuotes(t);
85
+ if (!bare) return false;
86
+ return isSecretBasename(basename(bare));
87
+ }
88
+
89
+ // --- Bash leg: quote-aware, statement-based scan -----------------------------
90
+
91
+ // Flags that take a separate-token value which could itself be the secret
92
+ // path (`-f .env`, `--file .env`), or (finding #2) a GLUED value on a short
93
+ // flag (`-f.env`). Kept small and generic: this hook doesn't need to model
94
+ // every tool's flag surface, just the common file-value shapes.
95
+ const VALUE_FLAGS = new Set(['-f', '--file']);
96
+
97
+ // The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
98
+ // trailhead-search-guard.js. Their first positional is a PATTERN, not a file
99
+ // operand, so it must be skipped before scanning for a secret FILE argument
100
+ // (finding #4: `grep ".env" config.txt` reads config.txt, not `.env`).
101
+ const SEARCH_VERBS = new Set(Object.keys(SEARCH_VERB_CONFIG));
102
+
103
+ // `bash -c "<cmd>"` / `sh -c "<cmd>"` / `zsh -c "<cmd>"` / `eval "<cmd>"`: the
104
+ // secret read hides inside that quoted string, invisible to a plain
105
+ // positional/flag scan (finding #1), so these are recursed into as a nested
106
+ // command.
107
+ const SHELL_DASH_C_VERBS = new Set(['bash', 'sh', 'zsh', 'ksh']);
108
+
109
+ // A token can still end with a glued shell metacharacter that splitStatements
110
+ // doesn't split on (grouping punctuation left over from a subshell/brace
111
+ // group, or a stray backtick closing a command substitution). Strip a run of
112
+ // these from the token's tail before basename-testing it, so e.g. the last
113
+ // token of `(cat .env)` (`.env)`) still resolves to `.env`.
114
+ const TRAILING_PUNCT_RE = /[|;&`)}]+$/;
115
+ function stripTrailingPunct(t) {
116
+ return String(t).replace(TRAILING_PUNCT_RE, '');
117
+ }
118
+
119
+ // Scan one already-tokenized statement's argument tokens (verb NOT stripped)
120
+ // for a secret path used as a plain positional, an option value
121
+ // (`--file=.env`, `-f .env`, glued `-f.env`), or a `<` (or glued `<file`)
122
+ // redirect target. Returns the offending token or null.
123
+ function scanTokensForSecretPath(tokens) {
124
+ for (let i = 0; i < tokens.length; i++) {
125
+ const t = stripTrailingPunct(tokens[i]);
126
+ if (!t) continue;
127
+
128
+ // `<` redirect: glued (`<.secrets`) or separate-token (`< .secrets`).
129
+ // Never confuse with a heredoc `<<TAG` (already stripped out by
130
+ // splitStatements upstream, but be defensive).
131
+ if (t.startsWith('<') && !t.startsWith('<<')) {
132
+ const glued = t.slice(1);
133
+ if (glued) {
134
+ if (isSecretPathArg(glued)) return glued;
135
+ continue;
136
+ }
137
+ const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
138
+ if (next && isSecretPathArg(next)) return next;
139
+ continue;
140
+ }
141
+
142
+ if (t.startsWith('-') && t !== '-') {
143
+ // `--flag=value` form.
144
+ const eq = t.indexOf('=');
145
+ if (eq > 0) {
146
+ const val = t.slice(eq + 1);
147
+ if (isSecretPathArg(val)) return val;
148
+ continue;
149
+ }
150
+ // `-f value` / `--file value` (separate-token value).
151
+ if (VALUE_FLAGS.has(t)) {
152
+ const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
153
+ if (next && isSecretPathArg(next)) return next;
154
+ continue;
155
+ }
156
+ // Glued short-option value (`-f.env`, finding #2): a single-dash flag
157
+ // whose flag letter is a known value flag, with the value glued
158
+ // directly onto it.
159
+ if (!t.startsWith('--') && t.length > 2) {
160
+ const shortFlag = t.slice(0, 2);
161
+ if (VALUE_FLAGS.has(shortFlag)) {
162
+ const glued = t.slice(2);
163
+ if (isSecretPathArg(glued)) return glued;
164
+ }
165
+ }
166
+ continue; // some other flag: not itself a file operand
167
+ }
168
+
169
+ // Plain positional (file operand or search pattern token).
170
+ if (isSecretPathArg(t)) return t;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ // Does `stmtTokens` invoke `bash -c "<cmd>"` / `sh -c "<cmd>"` / `eval
176
+ // "<cmd>"` (etc.)? If so, return the inner command TEXT (quotes stripped, not
177
+ // yet re-tokenized) so the caller can recurse into it as a nested command.
178
+ // Best-effort: only the common single-quoted-argument idiom, not every way a
179
+ // shell can be told to execute a string.
180
+ function extractNestedCommand(stmtTokens) {
181
+ if (!stmtTokens.length) return null;
182
+ const bin = basename(stmtTokens[0]);
183
+ if (bin === 'eval') {
184
+ const rest = stmtTokens.slice(1);
185
+ if (!rest.length) return null;
186
+ return stripQuotes(rest.join(' '));
187
+ }
188
+ if (SHELL_DASH_C_VERBS.has(bin)) {
189
+ const cIdx = stmtTokens.indexOf('-c');
190
+ if (cIdx === -1 || cIdx + 1 >= stmtTokens.length) return null;
191
+ return stripQuotes(stmtTokens.slice(cIdx + 1).join(' '));
192
+ }
193
+ return null;
194
+ }
195
+
196
+ // Scan a single top-level statement for a secret path. Recurses into a
197
+ // bash -c/eval quoted sub-command first (finding #1), routes grep/rg/ag/sed/
198
+ // awk through the shared pattern-skipping walk (finding #4), and falls back
199
+ // to the plain per-token walk for everything else (cat, head, redirects,
200
+ // etc.).
201
+ function scanStatementForSecretPath(stmt) {
202
+ const tokens = tokenize(stmt);
203
+ if (!tokens.length) return null;
204
+
205
+ const nested = extractNestedCommand(tokens);
206
+ if (nested) {
207
+ const hit = scanBashForSecretPath(nested);
208
+ if (hit) return hit;
209
+ // fall through: also scan the wrapper statement's own tokens (e.g. a
210
+ // stray `-f .env` on the wrapper itself) for defence in depth.
211
+ }
212
+
213
+ const bin = basename(tokens[0]);
214
+ if (SEARCH_VERBS.has(bin)) {
215
+ return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
216
+ }
217
+ return scanTokensForSecretPath(tokens);
218
+ }
219
+
220
+ // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
221
+ // `||`, and newlines all split, even with no surrounding whitespace, e.g.
222
+ // `cat .env|grep KEY`) looking for a secret path used as a plain positional,
223
+ // an option value, or a `<` redirect target. Returns the offending token
224
+ // (quotes/punctuation not necessarily stripped) or null. Best-effort: not a
225
+ // full shell parser.
226
+ function scanBashForSecretPath(cmd) {
227
+ const statements = splitStatements(cmd);
228
+ for (const stmt of statements) {
229
+ const hit = scanStatementForSecretPath(stmt);
230
+ if (hit) return hit;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ // --- top-level detection -----------------------------------------------------
236
+
237
+ // Detect a secret-file read across a Read or Bash tool call. Returns
238
+ // { path } on a hit, or null. Pure, exported for tests.
239
+ function detectSecretRead(toolName, toolInput) {
240
+ const input = toolInput || {};
241
+ if (toolName === 'Read') {
242
+ const p = input.file_path;
243
+ if (p && isSecretPathArg(String(p))) return { path: String(p) };
244
+ return null;
245
+ }
246
+ if (toolName === 'Bash') {
247
+ const cmd = input.command || '';
248
+ const hit = scanBashForSecretPath(cmd);
249
+ return hit ? { path: hit } : null;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ function block(hit) {
255
+ const reason =
256
+ `trailhead secret-read guard: blocked a read of secret file \`${hit.path}\` ` +
257
+ '(.env / .env.* / .secrets). This replaces the Read() deny permission rules ' +
258
+ "removed in #135 with a hook-level deny, so secrets stay unreadable without " +
259
+ "arming Claude Code 2.1.259's cd-compound-read approval prompt. If this file " +
260
+ 'genuinely needs inspecting, do so outside the agent session.';
261
+ process.stdout.write(JSON.stringify({ decision: 'block', code: 'SECRET_READ_BLOCKED', reason }));
262
+ process.exit(2);
263
+ }
264
+
265
+ function run(data) {
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(data);
269
+ } catch {
270
+ process.exit(0); // unparseable input: nothing to gate
271
+ }
272
+ try {
273
+ const hit = detectSecretRead(parsed.tool_name, parsed.tool_input);
274
+ if (hit) block(hit);
275
+ } catch {
276
+ // fall through to allow: a guard must never wedge the workflow
277
+ }
278
+ process.exit(0);
279
+ }
280
+
281
+ if (require.main === module) {
282
+ let data = '';
283
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
284
+ process.stdin.on('data', (c) => (data += c));
285
+ process.stdin.on('end', () => {
286
+ clearTimeout(timer);
287
+ run(data);
288
+ });
289
+ }
290
+
291
+ module.exports = { detectSecretRead };
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-secret-read-guard.js. Run: node trailhead-secret-read-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+ const { detectSecretRead } = require('./trailhead-secret-read-guard.js');
8
+
9
+ const HOOK = path.join(__dirname, 'trailhead-secret-read-guard.js');
10
+
11
+ let passed = 0;
12
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
13
+
14
+ // Run the hook end-to-end: pipe {tool_name, tool_input} JSON on stdin, capture {code, out}.
15
+ function runHook(toolName, toolInput) {
16
+ const input = JSON.stringify({ tool_name: toolName, tool_input: toolInput });
17
+ try {
18
+ const out = execFileSync('node', [HOOK], { input });
19
+ return { code: 0, out: out.toString() };
20
+ } catch (e) {
21
+ return { code: e.status, out: (e.stdout || '').toString(), err: (e.stderr || '').toString() };
22
+ }
23
+ }
24
+
25
+ // --- pure seam: detectSecretRead ---
26
+
27
+ // --- DENY cases ---
28
+ ok('Read .env is denied', !!detectSecretRead('Read', { file_path: '.env' }));
29
+ ok('Read config/.env.local is denied', !!detectSecretRead('Read', { file_path: 'config/.env.local' }));
30
+ ok('Read .secrets is denied', !!detectSecretRead('Read', { file_path: '.secrets' }));
31
+ ok('Bash cat .env is denied', !!detectSecretRead('Bash', { command: 'cat .env' }));
32
+ ok('Bash cat < .secrets is denied', !!detectSecretRead('Bash', { command: 'cat < .secrets' }));
33
+ ok('Bash grep -n KEY .env.production is denied', !!detectSecretRead('Bash', { command: 'grep -n KEY .env.production' }));
34
+ ok('Bash cat app/.env.local is denied', !!detectSecretRead('Bash', { command: 'cat app/.env.local' }));
35
+ ok('Bash cat --file=.env is denied', !!detectSecretRead('Bash', { command: 'cat --file=.env' }));
36
+
37
+ // --- ALLOW cases (no false positives) ---
38
+ ok('Read src/app.ts is allowed', !detectSecretRead('Read', { file_path: 'src/app.ts' }));
39
+ ok('Bash grep -n x pubspec.yaml is allowed', !detectSecretRead('Bash', { command: 'grep -n x pubspec.yaml' }));
40
+ ok('Bash cat README.md is allowed', !detectSecretRead('Bash', { command: 'cat README.md' }));
41
+ ok('Bash cd app && grep x pubspec.yaml is allowed (not a secret; search-guard governs the cd shape)',
42
+ !detectSecretRead('Bash', { command: 'cd app && grep x pubspec.yaml' }));
43
+ ok('Read env.sample (near-miss) is allowed', !detectSecretRead('Read', { file_path: 'env.sample' }));
44
+ ok('Read .environment (near-miss) is allowed', !detectSecretRead('Read', { file_path: '.environment' }));
45
+
46
+ // --- #135 follow-up: Finding 1 - glued shell metacharacters must not bypass detection ---
47
+ ok('Bash cat .env|grep KEY is denied (glued pipe)', !!detectSecretRead('Bash', { command: 'cat .env|grep KEY' }));
48
+ ok('Bash cat .env;echo done is denied (glued semicolon)', !!detectSecretRead('Bash', { command: 'cat .env;echo done' }));
49
+ ok('Bash cat .env&&echo done is denied (glued &&)', !!detectSecretRead('Bash', { command: 'cat .env&&echo done' }));
50
+ ok('Bash bash -c "cat .env" is denied (quoted sub-command)', !!detectSecretRead('Bash', { command: 'bash -c "cat .env"' }));
51
+ ok('Bash eval "cat .env" is denied (quoted sub-command)', !!detectSecretRead('Bash', { command: 'eval "cat .env"' }));
52
+
53
+ // --- #135 follow-up: Finding 2 - glued short-option value must not bypass detection ---
54
+ ok('Bash cat -f.env is denied (glued short-flag value)', !!detectSecretRead('Bash', { command: 'cat -f.env' }));
55
+
56
+ // --- #135 follow-up: Finding 3 - bare .env inside quoted prose must not false-positive ---
57
+ ok('Bash git commit -m "document .env usage" is allowed (quoted prose, not a file operand)',
58
+ !detectSecretRead('Bash', { command: 'git commit -m "document .env usage"' }));
59
+ ok('Bash echo "See .env for config" >> README.md is allowed (quoted prose)',
60
+ !detectSecretRead('Bash', { command: 'echo "See .env for config" >> README.md' }));
61
+ ok('Bash cat ".env" is still denied (a quoted LONE path is still a secret path)',
62
+ !!detectSecretRead('Bash', { command: 'cat ".env"' }));
63
+
64
+ // --- #135 follow-up: Finding 4 - quoted grep pattern must not false-positive ---
65
+ ok('Bash grep ".env" config.txt is allowed (the read target is config.txt, not the pattern)',
66
+ !detectSecretRead('Bash', { command: 'grep ".env" config.txt' }));
67
+ ok("Bash grep '.env' config.txt is allowed (single-quoted pattern)",
68
+ !detectSecretRead('Bash', { command: "grep '.env' config.txt" }));
69
+ ok('Bash grep -n KEY .env.production is still denied (secret is the FILE operand, not the pattern)',
70
+ !!detectSecretRead('Bash', { command: 'grep -n KEY .env.production' }));
71
+
72
+ // --- #135 follow-up: Finding 5 - case-insensitivity (same file on macOS/Windows) ---
73
+ ok('Read .ENV is denied (case-insensitive)', !!detectSecretRead('Read', { file_path: '.ENV' }));
74
+ ok('Bash cat .SECRETS is denied (case-insensitive)', !!detectSecretRead('Bash', { command: 'cat .SECRETS' }));
75
+ ok('Read ENV.SAMPLE (near-miss) is still allowed regardless of case', !detectSecretRead('Read', { file_path: 'ENV.SAMPLE' }));
76
+ ok('Read .ENVIRONMENT (near-miss) is still allowed regardless of case', !detectSecretRead('Read', { file_path: '.ENVIRONMENT' }));
77
+
78
+ // --- end-to-end hook wire format ---
79
+ const d1 = runHook('Read', { file_path: '.env' });
80
+ ok('hook denies Read(.env) end-to-end (exit 2, block decision)', d1.code === 2 && /"decision":"block"/.test(d1.out));
81
+
82
+ const d2 = runHook('Bash', { command: 'cat .env' });
83
+ ok('hook denies Bash(cat .env) end-to-end (exit 2, block decision)', d2.code === 2 && /"decision":"block"/.test(d2.out));
84
+
85
+ const a1 = runHook('Read', { file_path: 'src/app.ts' });
86
+ ok('hook allows Read(src/app.ts) end-to-end (exit 0, no output)', a1.code === 0 && a1.out.trim() === '');
87
+
88
+ const a2 = runHook('Bash', { command: 'cd app && grep x pubspec.yaml' });
89
+ ok('hook allows Bash(cd app && grep x pubspec.yaml) end-to-end (exit 0, no output)', a2.code === 0 && a2.out.trim() === '');
90
+
91
+ // --- crash safety ---
92
+ const crash1 = runHook('Read', undefined);
93
+ ok('missing tool_input never crashes (exit 0)', crash1.code === 0);
94
+
95
+ console.log(`✓ secret-read-guard: ${passed} assertions passed`);
@@ -74,6 +74,8 @@ On **trivial/small** the engine surfaces **one consolidated skip offer** and act
74
74
 
75
75
  **Never in the offer** (always kept, whatever the size or the answer): atomic `Refs:` commits, running the tests / the plan's verification criterion, the post-commit self-check, `bug`'s Diagnose (a confirmed root cause is mandatory before Fix), and Resolve. `effort` scales *redundant heavy* work to the change; it never drops a correctness check.
76
76
 
77
+ **`auto` has no config surface.** The autonomous run (`/trailhead:auto`, `../trailhead-work/references/auto.md`) takes no key here, by decision: it changes neither `effort` nor `models.*` (orthogonal to both). Its safety rail is hardcoded always-on, never a toggle, and its stop/go boundary is the same `choices.md` one-line test every other engine step uses (delegate options run without the confirm gate; a human-necessary choice is always set aside), so there is nothing to configure separately.
78
+
77
79
  Example `config.json` (same shape for the project `.trailhead/config.json` and the global `~/.claude/trailhead/config.json`):
78
80
  ```json
79
81
  { "models": { "plan": "claude-opus-4-8", "execute": "claude-sonnet-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
@@ -153,7 +153,7 @@ gh issue list --label "trailhead:map-<n>" --state open --json number --jq 'lengt
153
153
  gh issue view <map> --json body --jq .body
154
154
  ```
155
155
 
156
- **When to refresh the body.** On structural events: a map is charted or exhausted (a map appears or disappears), a whiteboard ticket is born or resolved. And on demand via `/trailhead:dashboard`. NOT on every map-ticket resolve: that churns a pinned issue's notifications, and the native progress bar already tracks map-ticket progress on its own. The whiteboard has no native progress bar, so its ticket birth/close does refresh the dashboard.
156
+ **When to refresh the body.** On structural events: a map is charted or exhausted (a map appears or disappears), a whiteboard ticket is born or resolved. And on demand via `/trailhead:dashboard`. NOT on every map-ticket resolve: that churns a pinned issue's notifications, and the native progress bar already tracks map-ticket progress on its own. The whiteboard has no native progress bar, so its ticket birth or close refreshes the dashboard, except a `quick`-born ticket (born and resolved in one session) refreshes at the handoff (Resolve, or pause), not at creation.
157
157
 
158
158
  ## First-use repo setup (do ALL THREE, every chart or adopt, never skip any)
159
159
  1. Create any missing labels with `gh label create` (all eighteen: `trailhead:map`, `trailhead:codebase`, `trailhead:conventions`, `trailhead:dashboard`, `trailhead:ticket`, the six type labels, `trailhead:blocked`, `trailhead:seed`, `trailhead:out-of-scope`, `trailhead:superseded`, `trailhead:unverified`, `trailhead:fog`, `trailhead:whiteboard`).
@@ -7,7 +7,7 @@ The tracker is GitHub via the `gh` CLI (already authenticated). **Never post a s
7
7
  | Map | an issue with label `trailhead:map`. **Not pinned** (the 3 pinned slots are the repo-scoped [dashboard](../trailhead-view/SKILL.md#render-the-dashboard-dashboard) + codebase + conventions): open maps are found via the `trailhead:map` label and listed in the pinned dashboard, so a repo's maps stay one click away without contending for a slot. **When a map is exhausted** (destination reached: no open tickets and no fog left), **ask the user whether to close the map issue**: on a yes, `gh issue close` it (a closed issue is still the durable record) and refresh the dashboard so it drops off; on a no, leave it open. Never close a map unprompted; there is no pin to free either way. Each map's **tickets** share a **`trailhead:map-<n>` label** (n = the map's issue number) that scopes its frontier; the map issue itself is found by its number and does not carry it. |
8
8
  | Codebase | a **single per-repo** issue with label `trailhead:codebase`: the distilled codebase map (architecture, stack, conventions, decisions embodied, risks, test/build). It's repo-scoped: **shared by every map of this repo, owned by none**, so it survives when a map is finished. Each map's Notes *links* it (never re-inlines it). Not a `trailhead:ticket`, so it's off the frontier. Written at adopt; a **greenfield map** (`/trailhead:new`) has no code to map at chart, so it's created **later, once the repo has substantial code** (e.g. after the first build tickets land), via the Codebase map technique. **Kept fresh at Resolve**: when a resolved `build`/`bug` ticket **materially changes** the map (a new module or layer, a new dependency/integration, a new seam, a changed build/test command, a new convention), **patch the affected facet of this issue in place** as part of Resolve, one or two lines, not a re-run of the fan-out. Cosmetic or in-facet changes leave it alone; a full re-map is only for major drift. |
9
9
  | Conventions | a **single per-repo** issue with label `trailhead:conventions`: the project's **way of working**, readable by everyone. Repo-scoped and linked from every map's Notes, like Codebase. It opens with a small machine-read header the engine obeys, then human prose. Header keys (defaults **bold**): `git:` **`main`** \| `pr` (commit straight to `main`, or feature branch + PR) · `release:` **`command`** \| `auto` (never release without an explicit command, or release automatically per the project's flow) · `isolation:` **`none`** \| `worktree` \| `clone` (work in the current checkout; or give each executing ticket its own `git worktree` + `trailhead/t<n>` branch; or a dedicated per-ticket **clone** at `../<repo>-t<n>` for **path-bound apps a worktree can't build**, e.g. React Native/Expo with a local `node_modules`; see [Working as a team](teamwork.md#working-as-a-team)). **`worktree`/`clone` imply a branch per ticket even under `git: main`** (git won't check the trunk out twice); the branch integrates to the trunk per `git:` at Resolve. Under **`none`** two in-progress tickets with overlapping `Scope:` **serialise** (the second holds at claim). Below the header: the standing "how we work here" notes. **Filled at chart/adopt by a brief conventions brainstorming** (see the Invocation modes), not a `trailhead:ticket`, off the frontier. |
10
- | Dashboard | a **single per-repo** issue with label `trailhead:dashboard`: the pinned index of the whole trailhead surface. Repo-scoped and permanent, like Codebase and Conventions, and it occupies the **fixed 3rd pinned slot** alongside them (`gh api graphql` `pinIssue` mutation on its node id). It holds: a link to **every open `trailhead:map`** (GitHub renders each map's sub-issue **progress bar natively**, so the dashboard only links, it does not recompute per-ticket progress), the **whiteboard** as a section (or a link to the `/trailhead:whiteboard` view), and dynamic **counts** (untriaged inbox, whiteboard frontier size). **Created and pinned at the first chart/adopt**, then **rewritten on structural events** (a map is charted or exhausted, i.e. a map appears or disappears; a whiteboard ticket is born or resolved) **and on demand** via `/trailhead:dashboard`. **Not rewritten on every *map* ticket resolve** (that would churn a pinned issue and its notifications; each map's native progress bar already tracks its per-ticket progress, but the whiteboard has no progress bar, so its ticket birth/close does refresh the dashboard). **An exhausted-but-open map** (destination reached, but its issue never closed) is flagged `exhausted · closeable` here via a cheap per-map `0 open scoped issues?` pre-filter confirmed against remaining fog, and the render offers to close it (recurring across the dashboard render and the read-only self-heal entry points, never auto-closing), so a missed exhaustion moment gets a second chance. See [The dashboard](../trailhead-view/SKILL.md#render-the-dashboard-dashboard). |
10
+ | Dashboard | a **single per-repo** issue with label `trailhead:dashboard`: the pinned index of the whole trailhead surface. Repo-scoped and permanent, like Codebase and Conventions, and it occupies the **fixed 3rd pinned slot** alongside them (`gh api graphql` `pinIssue` mutation on its node id). It holds: a link to **every open `trailhead:map`** (GitHub renders each map's sub-issue **progress bar natively**, so the dashboard only links, it does not recompute per-ticket progress), the **whiteboard** as a section (or a link to the `/trailhead:whiteboard` view), and dynamic **counts** (untriaged inbox, whiteboard frontier size). **Created and pinned at the first chart/adopt**, then **rewritten on structural events** (a map is charted or exhausted, i.e. a map appears or disappears; a whiteboard ticket is born or resolved) **and on demand** via `/trailhead:dashboard`. **Not rewritten on every *map* ticket resolve** (that would churn a pinned issue and its notifications; each map's native progress bar already tracks its per-ticket progress, but the whiteboard has no progress bar, so a whiteboard ticket's birth or close refreshes the dashboard, with one timing exception: a `quick`-born ticket, born and resolved in one session, refreshes at its handoff (Resolve, or pause), not at creation). **An exhausted-but-open map** (destination reached, but its issue never closed) is flagged `exhausted · closeable` here via a cheap per-map `0 open scoped issues?` pre-filter confirmed against remaining fog, and the render offers to close it (recurring across the dashboard render and the read-only self-heal entry points, never auto-closing), so a missed exhaustion moment gets a second chance. See [The dashboard](../trailhead-view/SKILL.md#render-the-dashboard-dashboard). |
11
11
  | Ticket | a child issue with label `trailhead:ticket` + exactly one **type** label: `trailhead:decision` / `trailhead:research` / `trailhead:prototype` / `trailhead:build` / `trailhead:bug` / `trailhead:task`, plus its map's `trailhead:map-<n>` label and native sub-issue edge (see Child→map link). **A `trailhead:whiteboard` ticket carries none of the map wiring** (no `trailhead:map-<n>`, no sub-issue edge, no `Parent:`); see [The whiteboard](../trailhead-view/SKILL.md#render-the-whiteboard-whiteboard). |
12
12
  | Child→map link | three views of one fact, wired in the same pass (like a blocker): a `Parent: <map name>(link)` line in the ticket body (human-readable); a **native sub-issue** edge to the map (`gh api --method POST repos/{owner}/{repo}/issues/<map>/sub_issues -F sub_issue_id=<ticket .id>`, the structure GitHub renders); and the map's **`trailhead:map-<n>` label** on the ticket (the queryable key that scopes its map's frontier). See [Multiple maps on one repo](multi-map.md#multiple-maps-on-one-repo-parking--concurrency). **A whiteboard ticket has none of the three** (it belongs to no map). |
13
13
  | Path scope (monorepo) | an optional `Scope: <path>[, <path>…]` line in the ticket body naming the package/dir(s), or **submodule path(s)**, the ticket works in. It sharpens the [undeclared-coupling check](teamwork.md#working-as-a-team) (disjoint scopes parallelize safely), scopes build/test to the affected package, and scopes an `isolation: worktree` worktree's commits. **List every one it touches** (a ticket spanning two submodules names both). **Omit it in a single-package repo** (the whole tree is the scope); add it only where a repo has independent packages/subtrees or submodules. |
@@ -44,7 +44,7 @@ Prefer `worktree` whenever more than one session may run on a single clone. On a
44
44
 
45
45
  ## Pausing & resuming
46
46
  Switching away from a ticket mid-work (to clear a blocker, chase an urgent bug, or just stop) must not lose the thread.
47
- - **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.
47
+ - **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.
48
48
  - **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.
49
49
  - 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.
50
50
 
@@ -8,7 +8,7 @@ Automated tests prove the code does what the *code* intended; acceptance testing
8
8
  - **On a failing step: stop, take note, open the closing ticket then and there.** A failed step is an acceptance finding, not a footnote: record expected-vs-seen, and immediately open the follow-up that will carry it to resolution: a `bug` ticket (with `Regression of:`/`Surfaced from:` as fits), or a fix on this ticket before it resolves if it's small and in-scope. Then ask the human whether to keep going through the remaining steps or stop here. Never silently log a fail and carry on to Resolve.
9
9
  - **When only some steps are runnable now** (e.g. a few need a backend deployed first), walk the runnable ones conversationally **now**, and mark the rest **explicitly pending** with what unblocks them, tracked as below. Don't defer the whole UAT just because part of it is blocked.
10
10
  - **When the human can only run it later** (on-device, hardware you don't have right now), record the full numbered checklist in the `VERIFY` comment as the durable artifact and mark UAT **explicitly pending**, but the walkthrough stays conversational **when they come to run it**: never shrink it to a vague one-liner ("just check it on the device"), and never let "automated passed" quietly stand in for it. Deferring *where/when* the human runs it is fine; replacing the live step-by-step with a self-serve checklist is not.
11
- - **Pending UAT must stay tracked: a closed ticket is not tracking.** Don't resolve/close a user-facing ticket on unconfirmed acceptance and leave the checklist as a comment on the closed issue, where nobody will look. Either **keep the ticket open** until the human confirms the UAT passes (the default: closing on green you didn't see is a premature close), **or**, if the project deliberately batches on-device UAT, spin a follow-up **`task`** that carries the checklist and sits on the frontier until it's run. The pending check must be visible on the tracker, not buried.
11
+ - **Pending UAT must stay tracked: a closed ticket is not tracking.** Don't resolve/close a user-facing ticket on unconfirmed acceptance and leave the checklist as a comment on the closed issue, where nobody will look. The default is to **keep the ticket open** until the human confirms the UAT passes (closing on green you didn't see is a premature close). **If the human instead accepts closing now and running the UAT later**, you **must open a follow-up ticket before that close** that carries the pending UAT and links back to the parent with a `UAT of: <parent name>(link)` pointer in the body. Route it the way `../choices.md` routes a deferred choice: a **`trailhead:seed`** (kept blocked) when the run is gated on a future external trigger not yet true (hardware you don't have, a device out of reach), or a follow-up **`task`** on the frontier when it's just scheduled or batched work you could pick up any time. Either way the follow-up **carries the full numbered checklist and, when it comes to resolution, runs the guided conversational walkthrough above**, never a self-serve check-off. **Never close a user-facing ticket on deferred UAT with nothing opened**: the parent may close as agreed, but the owed UAT stays visible on the tracker as its own ticket, linked to the parent, until it is actually performed. The pending check must be visible on the tracker, not buried.
12
12
 
13
13
  Acceptance failures are findings like any other: a `bug` follow-up or a fix on this ticket before it resolves.
14
14
 
@@ -1,5 +1,8 @@
1
1
  # Codebase map (fan-out)
2
2
  One-time at project adoption. Fan out 5 read-only reader subagents in parallel (single message), each owning ONE non-overlapping facet; no facet reads another's territory. Dispatch each reader as the **`trailhead-codebase-map`** agent (read-only by construction); **never** an external-plugin mapper agent that another installed tool registered.
3
+
4
+ **The repo to map may be handed in by absolute path** (the scan root), so **never assume the current working directory is that repo**: search and read it by absolute path and run git as `git -C <scan root> ...`, and **never `cd` (or `pushd`/`env -C`) into it in any shape** (no `cd <root> && grep <relative>`, no bare `cd <root>` line then a relative read, no heredoc/multi-statement block, and not as a standalone `cd <root>` in an earlier Bash call: the cwd persists across calls). This keeps every read target statically determinable, so a bypass-permissions scan of an out-of-tree repo runs without a manual approval prompt. See **Search-command hygiene** in `../techniques.md`.
5
+
3
6
  - **Architecture**: pattern, layers, data flow, entry points, module seams; where the deep modules live.
4
7
  - **Stack & dependencies**: languages, runtime, frameworks, manifests, build/config, external integrations (DBs, APIs, auth, webhooks).
5
8
  - **Decisions already embodied / conventions**: style, naming, error handling, the repo's documented standards; patterns a new change must match.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The ticket engines call these by name. Each technique's full protocol lives in its own file under `techniques/`: **read that file the first time a session needs the technique**, so only the ones in play load into context (don't preload them). They are `trailhead`'s own, self-contained; each technique is dispatched to its **trailhead-owned agent** (`trailhead-<technique>`, shipped as committed `agents/trailhead-*.md`) via the `Agent` tool, with the technique's full protocol injected at spawn.
4
4
 
5
- **Dispatch each technique to its trailhead-owned agent.** Every technique subagent carries its full protocol in the prompt (from its `techniques/` file), so the agent definition stays **thin**: identity + tool policy, model-agnostic. Dispatch each technique to **its own `trailhead-<technique>` agent** (set `subagent_type` accordingly): the Plan step → `trailhead-plan`, Execute → `trailhead-executor`, Research → `trailhead-research`, Code review → `trailhead-code-review`, Debug → `trailhead-debug`, Fix → `trailhead-fix`, Codebase map → `trailhead-codebase-map`, Goal-backward verification → `trailhead-verify`. These agents ship with trailhead (committed `agents/trailhead-*.md`) and are registered wherever trailhead is installed as a Claude Code **plugin** (its `agents/` load automatically), or its agent files are otherwise present as subagent types. **If a `trailhead-<technique>` agent is NOT registered in the current environment** (the `Agent`/`Task` tool reports the type unknown, or it is absent from the available-agents list, as on a skills-only install that did not register the agents), **run that technique inline in the main session**, following its `techniques/` protocol directly. Do **not** divert to a host built-in (`general-purpose`/`Explore`) or any external-plugin agent as the engine target. Inline is a graceful degradation: the technique still runs, only on the session model rather than its `config.models.<key>` pin (the same inline path used when that model equals the session model). The dispatch rule is therefore: the real `trailhead-<technique>` agent when it is registered, else inline in the main session, never a foreign agent. **Pass the `config.models.<key>` model as the `Agent`-tool `model` override at spawn** so config stays authoritative (the `.md` is model-agnostic; the override wins), running inline instead when that model is unset or equals the session model. On Claude Code the `Agent` tool's `model` parameter accepts only the short family aliases (`opus` / `sonnet` / `haiku` / `fable`), not the full ids `config.models.*` stores, so **map the configured id to its family alias only for the literal `model` value passed at spawn**: `claude-opus-*` -> `opus`, `claude-sonnet-*` -> `sonnet`, `claude-haiku-*` -> `haiku`, `claude-fable-*` -> `fable`; if an id matches none of these, don't guess: warn and inherit the session model (per `configuration-reference.md`). The alias names a family, not a version, so the host resolves it to that family's current model: the exact version the id pinned cannot be conveyed to the `Agent` tool and is not honoured at subagent spawn. **Keep the inline-vs-subagent decision on the full id**, running inline only when `config.models.<key>` is unset or its full id equals the session model's, never on the alias, so a deliberate same-family but different-version pin (e.g. `claude-opus-5` under a `claude-opus-4-8` session) still dispatches instead of collapsing to inline. (Codex projects `models.codex.*`, OpenAI ids, separately and is unaffected by this.) **Never dispatch to an external-plugin agent** (a `gsd-*`, `*-reviewer`, `*-researcher`, `*-mapper`, or similarly named agent another installed tool registered): it follows *its own* protocol and artifact conventions (a foreign code-review agent writes its own review file, not trailhead's `VERIFY` ticket comment), silently swapping trailhead's engine for another tool's. Host built-ins (`Explore` / `general-purpose`) stay available in general but are **no longer the engine-dispatch target**. The tool policy is baked into each agent's `tools:` field, mirroring the old split: **read-only** (`trailhead-plan`, `trailhead-code-review`, `trailhead-codebase-map`, `trailhead-verify`: reads, searches, runs tests; no Edit/Write) vs **full** (`trailhead-executor`, `trailhead-research`, `trailhead-debug`, `trailhead-fix`: can Edit/Write/commit). On a `subagentToolkit == none` host there is no fan-out and this is moot: every technique runs inline in the one session. (Codex is **not** such a host: it has the native multi_agent toolkit, so techniques fan out there too, dispatched by `agent_type` to the projected `~/.codex/agents/trailhead-*.toml`; see the Codex adapter's §D.)
5
+ **Dispatch each technique to its trailhead-owned agent.** Every technique subagent carries its full protocol in the prompt (from its `techniques/` file), so the agent definition stays **thin**: identity + tool policy, model-agnostic. Dispatch each technique to **its own `trailhead-<technique>` agent** (set `subagent_type` accordingly): the Plan step → `trailhead-plan`, Execute → `trailhead-executor`, Research → `trailhead-research`, Code review → `trailhead-code-review`, Debug → `trailhead-debug`, Fix → `trailhead-fix`, Codebase map → `trailhead-codebase-map`, Goal-backward verification → `trailhead-verify`. These agents ship with trailhead (committed `agents/trailhead-*.md`) and are registered wherever trailhead is installed as a Claude Code **plugin** (its `agents/` load automatically), or its agent files are otherwise present as subagent types. **If a `trailhead-<technique>` agent is NOT registered in the current environment** (the `Agent`/`Task` tool reports the type unknown, or it is absent from the available-agents list, as on a skills-only install that did not register the agents), **run that technique inline in the main session**, following its `techniques/` protocol directly. Do **not** divert to a host built-in (`general-purpose`/`Explore`) or any external-plugin agent as the engine target. Inline is a graceful degradation: the technique still runs, only on the session model rather than its `config.models.<key>` pin (the same inline path used when that model equals the session model). The dispatch rule is therefore: the real `trailhead-<technique>` agent when it is registered, else inline in the main session, never a foreign agent. **Pass the `config.models.<key>` model as the `Agent`-tool `model` override at spawn** so config stays authoritative (the `.md` is model-agnostic; the override wins), running inline instead when that model is unset or equals the session model. On Claude Code the `Agent` tool's `model` parameter accepts only the short family aliases (`opus` / `sonnet` / `haiku` / `fable`), not the full ids `config.models.*` stores, so **map the configured id to its family alias only for the literal `model` value passed at spawn**: `claude-opus-*` -> `opus`, `claude-sonnet-*` -> `sonnet`, `claude-haiku-*` -> `haiku`, `claude-fable-*` -> `fable`; if an id matches none of these, don't guess: warn and inherit the session model (per `configuration-reference.md`). The alias names a family, not a version, so the host resolves it to that family's current model: the exact version the id pinned cannot be conveyed to the `Agent` tool and is not honoured at subagent spawn. **Keep the inline-vs-subagent decision on the full id**, running inline only when `config.models.<key>` is unset or its full id equals the session model's, never on the alias, so a deliberate same-family but different-version pin (e.g. `claude-opus-5` under a `claude-opus-4-8` session) still dispatches instead of collapsing to inline. (Codex projects `models.codex.*`, OpenAI ids, separately and is unaffected by this.) **Never dispatch to an external-plugin agent** (a `gsd-*`, `*-reviewer`, `*-researcher`, `*-mapper`, or similarly named agent another installed tool registered): it follows *its own* protocol and artifact conventions (a foreign code-review agent writes its own review file, not trailhead's `VERIFY` ticket comment), silently swapping trailhead's engine for another tool's. Host built-ins (`Explore` / `general-purpose`) stay available in general but are **no longer the engine-dispatch target**. The tool policy is baked into each agent's `tools:` field, mirroring the old split: **read-only** (`trailhead-plan`, `trailhead-code-review`, `trailhead-codebase-map`, `trailhead-verify`: reads, searches, runs tests; no Edit/Write) vs **full** (`trailhead-executor`, `trailhead-research`, `trailhead-debug`, `trailhead-fix`: can Edit/Write/commit). **Search-command hygiene (every fan-out subagent, and any built-in `Explore`/`general-purpose` fan-out the engine leans on).** Prefer the Grep and Read tools over shelling out to search or read a file; when a Bash `grep`/read is genuinely needed, pass the path as an **explicit argument** (`grep -niE "<patterns>" <abs>/<file>`, `wc -l <abs>/<file>`) and run git as `git -C <abs> ...`. **Never `cd` (or `pushd`/`env -C`) into the target directory first, in any shape**: not `cd <abs> && grep <relative>`, not a bare `cd <abs>` line followed by a relative `grep`/`wc` on the next line or after a `;`, not inside a heredoc or any other multi-statement block, and not as its own separate Bash call or turn (the shell working directory persists across Bash calls, so a standalone `cd <abs>` still leaves a later relative read non-static). Any `cd` makes the post-`cd` working directory non-static, so under bypass permissions with a `Read()` deny rule the harness cannot resolve the read target against the allow rules and falls back to a manual approval prompt (defeating headless operation); a path-explicit target (absolute-path argument, `git -C`) stays statically determinable and runs uninterrupted regardless of how the statements are separated. When the engine spawns a built-in `Explore`/`general-purpose` search, state this rule in its spawn prompt. On a `subagentToolkit == none` host there is no fan-out and this is moot: every technique runs inline in the one session. (Codex is **not** such a host: it has the native multi_agent toolkit, so techniques fan out there too, dispatched by `agent_type` to the projected `~/.codex/agents/trailhead-*.toml`; see the Codex adapter's §D.)
6
6
 
7
7
  **Reviews never block the session (engine-wide, every host).** The three **review** techniques, **Code review** (build/bug Verify), **Goal-backward verification** (build/bug Verify), and **Cross-AI plan review** (build Plan), always run as a **background job collected by polling**, never a blind open-ended blocking wait. This is a hard default: not a `config` toggle, and not a Codex-only or fallback posture. Because trailhead works one ticket at a time and Resolve depends on the review's outcome, "background" here means a **poll loop with a one-line progress note, the session responsive and interruptible between polls**, the outcome always awaited; what is banned is the blind freeze, never "proceed without the result." On Claude Code the `Agent` tool already backgrounds the review and notifies on completion, so poll its progress instead of awaiting it blindly; on Codex, collect it through the §D bounded-wait poll loop (external plan-review CLIs via §H). **Acceptance testing is excluded**: it is interactive by design (guided UAT / browser-drive with the user in the loop), not a blind wait to background.
8
8