@marcomigozzi/trailhead 0.6.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 (48) hide show
  1. package/README.it.md +12 -6
  2. package/README.md +12 -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-commit-guard.js +13 -2
  23. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +101 -0
  24. package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
  25. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
  26. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
  27. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
  28. package/plugins/trailhead/skills/_shared/choices.md +77 -0
  29. package/plugins/trailhead/skills/_shared/configuration-reference.md +4 -2
  30. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -3
  31. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  32. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  33. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  34. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  35. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
  36. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  37. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  38. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  39. package/plugins/trailhead/skills/_shared/techniques/verify.md +20 -3
  40. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  41. package/plugins/trailhead/skills/trailhead/SKILL.md +4 -2
  42. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -0
  43. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
  44. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -0
  45. package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -1
  46. package/plugins/trailhead/skills/trailhead-work/SKILL.md +19 -7
  47. package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
  48. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
@@ -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`);
@@ -0,0 +1,77 @@
1
+ # Framing a forced choice
2
+
3
+ When trailhead must put a **forced choice** to the human (any `AskUserQuestion`-style prompt it authors, or an options list in prose), it presents the real options and the host's built-in escape ("Other" / "Chat about this"). On top of those, two standardised agent-assisted options are available, each offered only where it fits. This is a **standing convention**, not a `config` toggle: it is always available at the call-sites below. (If the prompts ever prove intrusive, a future `config` key could gate them; not built today.)
4
+
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
+
7
+ ## The one-line test: whose choice is it?
8
+
9
+ 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?**
10
+
11
+ - **Yes, it is a project decision, so the human owns it.** Do NOT offer the delegate option (the agent must never both frame the options and pick a decision that shapes the destination; see the decision engine's "the choice stays theirs"). You may still offer **defer** if the choice is genuinely blocked on something not yet resolved.
12
+ - **No, it is an advisory / process choice** (how the engine does its work, not what the project decides, even though the step it belongs to may later be summarised in a resolution comment). Offer the **delegate** option.
13
+
14
+ The distinction is "is this answer itself a project decision" versus "does this answer just happen to end up mentioned when a ticket resolves": every ticket's Resolve updates `Decisions so far`, so *being mentioned there* is not the test; *being a decision that shapes the work* is.
15
+
16
+ Positive (delegate offered): config values and the `/trailhead config` menus, model/tooling picks, the effort skip offer, cross-AI plan-review convergence, "which fix approach" at a verify checkpoint, a prototype-or-not ask, a badge style, a lychee flag, a branch/worktree mechanism.
17
+
18
+ Negative (delegate NOT offered): `decision`-ticket convergence, a scope or destination change, an out-of-scope ruling, routing a deferred line to seed/idea/todo, whether to close an exhausted map, a claim collision. These are the human's to make (or already are a routing the human owns).
19
+
20
+ ## Option 1: "Let the agent pick (with rationale, then confirm)"
21
+
22
+ The delegate option. It does not let the agent silently decide: it is a **two-step, confirm-gated** interaction.
23
+
24
+ 1. The human selects the delegate option.
25
+ 2. The agent states **which option it would choose and why it beats the others** (concise, honest, the real trade-off), then asks a plain **yes/no confirmation**.
26
+ - **Yes** → act on the chosen option.
27
+ - **No, a counter-proposal, a "let's chat", or silence** → the choice **re-opens** (back to the menu / discussion). Never proceed on anything but an explicit yes. The human may counter-propose (the agent adopts it and re-confirms) or defer at this point.
28
+
29
+ This is **distinct from marking one option `(Recommended)`**, a static hint the agent may still use in the prompt: the recommendation pre-labels an option; the delegate option hands the pick to the agent under a confirm gate. Both may appear in the same prompt.
30
+
31
+ ## Option 2: "Defer this decision"
32
+
33
+ For a choice whose best answer depends on something **not yet resolved**. It never means "the agent decides later on its own": it routes the choice into trailhead's existing dependency machinery so it resurfaces at the right time.
34
+
35
+ - **Depends on another not-yet-made map decision** → open a `decision` ticket for it and wire it `blocked_by` that prerequisite (the blocker three-move in `substrate.md`), labelled `trailhead:blocked`. It graduates onto the frontier when the prerequisite closes.
36
+ - **Depends on a future external trigger** (not a map decision) → capture a `seed` (`trailhead:seed`, kept blocked) gated on that trigger.
37
+ - **No substrate to route into** (a `/trailhead config` menu, or a map-less choice with no real dependency) → **defer is not offered**; make the choice now (delegate or chat). Defer is offered only when there is a genuine dependency to wait on.
38
+
39
+ When the deferred choice was blocking **this** ticket's own progress (e.g. a `build` ticket that stopped mid-Discuss on the ambiguity), also wire the ticket that raised it `## Blocked by` the new `decision` ticket, or pause it per `teamwork.md`, so it leaves the frontier until the prerequisite resolves. That ticket cannot finish this session; hand off per the session ritual.
40
+
41
+ ## How to inject them (the recipe)
42
+
43
+ This is the operational recipe every applicable call-site follows. It is **mandatory, not optional**: where the table below marks an affordance applicable, it is **offered**, never merely "available". Applicability is set by the one-line test above and the table (delegate on advisory / process choices; defer only when there is a genuine dependency to wait on).
44
+
45
+ **Fixed labels.** Author the affordances with these canonical labels, rendered in the user's **conversation language** (they appear in the live prompt, not on the Issue, so `ticket.language` never governs them, as with any chat-facing prompt):
46
+ - Delegate -> "Let the agent pick (with rationale, then confirm)" (IT: "Lascia scegliere all'agente (con motivazione, poi conferma)", short "scegli tu").
47
+ - Defer -> "Defer this decision" (IT: "Rimanda questa decisione", short "scelgo dopo").
48
+
49
+ **Menu call-sites (an `AskUserQuestion` trailhead authors): the cap rule.** The host tool accepts only 2-4 real options (plus its own auto escape), so the affordances can be crowded out. The rule is deterministic: **fill up to the cap, then fall back to prose.**
50
+ - If **(real options + applicable affordances) <= 4**, add each applicable affordance as an explicit extra option in the same menu.
51
+ - If they **would exceed 4**, keep the real options in the menu and offer the affordance(s) that did not fit in **one line of prose immediately after the menu** (or as an immediate follow-up question). The cap never silently swallows them.
52
+
53
+ 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.
54
+
55
+ **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.**
56
+
57
+ `(Recommended)` on a real option is a separate, static hint that may coexist with the affordances; it is not the delegate option.
58
+
59
+ ## Where each applies (call-site table)
60
+
61
+ | Call-site | Delegate | Defer |
62
+ |---|---|---|
63
+ | `decision` ticket converge (grilling) | No (human decides) | Yes, if blocked on a prerequisite |
64
+ | `build`/`quick` Discuss "stop and ask" | Yes | Yes |
65
+ | Effort skip offer (consolidated) | Yes | No (now-or-never process choice) |
66
+ | `/trailhead config` menus | Yes | No (no substrate) |
67
+ | Cross-AI plan-review convergence | Yes | Sometimes (a concern blocked on a prerequisite) |
68
+ | Verify checkpoint (which fix / how to proceed) | Yes for the advisory sub-choice; accept/resolve stays the human's | No |
69
+ | Prototype "want mockups?" | Yes | No |
70
+ | Teamwork split shape / submodule split | Yes (the shape is advisory) | No |
71
+ | Claim collision, isolation-mode suggestion | No (coordination stop) | No |
72
+ | Exhaustion "close the map?" | No (record the human owns) | No |
73
+ | Out-of-scope routing (deferred line → seed/idea/todo) | No (routing the human owns) | This IS a defer already |
74
+
75
+ 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.
76
+
77
+ **Compatibility with "the choice stays the human's."** The delegate option is compatible *because of* the confirm gate (the agent proposes, the human ratifies) and because it is withheld on destination-shaping decisions. Defer never hands a decision to the agent; it parks it on the substrate for a real decision later.
@@ -70,10 +70,12 @@ If a value is unset, it resolves to its **Balanced**-profile default for that ac
70
70
  - **small**: localized, one or two files, within existing seams, no new dependency, no architectural change.
71
71
  - **normal**: anything else. **Risk floor:** a change is **never** trivial/small, however few its lines, when it touches security or auth, a data migration, a public API or contract, money or permissions, or concurrency, or when it adds or updates a dependency; such a change is always `normal`. When in doubt, classify up.
72
72
 
73
- On **trivial/small** the engine surfaces **one consolidated skip offer** and acts only on an **explicit opt-in**: it names the concrete heavy steps proposed to skip for *this* change and the safety trade-off, then goes lean **only on a clear yes**. On a no, on silence, or on any ambiguity, it keeps the **full flow** (the fail-safe is always toward more checking, never less). The heavy steps it may offer to skip are the **plan subagent** (replaced by a short inline plan, never zero plan), **TDD**, **Cross-AI plan review**, the full adversarial **Code review** subagent (replaced by a lighter inline pass), and the **Goal-backward verification** subagent (replaced by a quick inline goal check); the two inline replacements are dropped entirely only when the change is truly cosmetic. The offer lists **only steps actually in play** for this ticket and config (it will not mention Cross-AI plan review when `plan_review` is off, or TDD when `tdd: off`). On **normal**, and always under `effort: standard`, there is no offer and the full flow runs.
73
+ On **trivial/small** the engine surfaces **one consolidated skip offer** and acts only on an **explicit opt-in**: it names the concrete heavy steps proposed to skip for *this* change and the safety trade-off, then goes lean **only on a clear yes**. On a no, on silence, or on any ambiguity, it keeps the **full flow** (the fail-safe is always toward more checking, never less). The heavy steps it may offer to skip are the **plan subagent** (replaced by a short inline plan, never zero plan), **TDD**, **Cross-AI plan review**, the full adversarial **Code review** subagent (replaced by a lighter inline pass), and the **Goal-backward verification** subagent (replaced by a quick inline goal check); the two inline replacements are dropped entirely only when the change is truly cosmetic. The offer lists **only steps actually in play** for this ticket and config (it will not mention Cross-AI plan review when `plan_review` is off, or TDD when `tdd: off`). On **normal**, and always under `effort: standard`, there is no offer and the full flow runs. This offer **always includes** the "let the agent pick (with rationale, then confirm)" delegate option, per the injection recipe in `choices.md`.
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" } }
@@ -81,7 +83,7 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
81
83
 
82
84
  ## Guided setup
83
85
 
84
- `/trailhead config` (no args) runs an **interactive walkthrough**: never make the user hand-edit JSON. Present each step as an `AskUserQuestion` menu with **icon-labelled options** and the current value pre-selected; write the answers to the chosen scope at the end and show a summary.
86
+ `/trailhead config` (no args) runs an **interactive walkthrough**: never make the user hand-edit JSON. Present each step as an `AskUserQuestion` menu with **icon-labelled options** and the current value pre-selected; write the answers to the chosen scope at the end and show a summary. These menus **offer** the delegate option (agent picks, with rationale, then confirm), per the injection recipe in `choices.md`: as an explicit menu option where the cap allows, otherwise in a prose line right after the menu (several of these steps already list 4 or more real options, so there delegate is offered in prose).
85
87
 
86
88
  **Never skip a step.** Walk every step in order **and** every conditional sub-step its branch activates; never omit one, and **never silently take a default for a step the flow reaches**. The pre-selected value is a convenience, not licence to not ask; a step counts as done only once the user has actually answered it. If a choice opens follow-ups (e.g. *pick specific CLIs* → which ones **and** how many rounds; *browser on* → base URL), ask **each** before moving on.
87
89
 
@@ -2,8 +2,9 @@
2
2
 
3
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. 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:
4
4
 
5
- 1. **A one-line confirmation** the ticket is resolved, by name (not a bare `#number`).
6
- 2. **The next step: a scannable block whose FIRST line is `/clear`, always.** Never bury it in prose, and **never point at the next ticket without leading with `/clear`**. `/clear` is its own first bullet, phrased as the first thing to do ("fai `/clear`, poi..."): **never demote it to a trailing parenthetical** on the next command. `/trailhead:work 29 (dopo /clear)` is the bug, not the fix: the reader runs the command and only then reads the note, when the context is already dirty. State the `/clear` **every time** (not only when context is heavy): it must come *before* the next command, on its own line first, so the next ticket starts on clean context. Render it as a short labelled list in the user's conversation language, along these lines:
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.
6
+ 2. **A one-line confirmation** the ticket is resolved, by name (not a bare `#number`).
7
+ 3. **The next step: a scannable block whose FIRST line is `/clear`, always.** Never bury it in prose, and **never point at the next ticket without leading with `/clear`**. `/clear` is its own first bullet, phrased as the first thing to do ("fai `/clear`, poi..."): **never demote it to a trailing parenthetical** on the next command. `/trailhead:work 29 (dopo /clear)` is the bug, not the fix: the reader runs the command and only then reads the note, when the context is already dirty. State the `/clear` **every time** (not only when context is heavy): it must come *before* the next command, on its own line first, so the next ticket starts on clean context. Render it as a short labelled list in the user's conversation language, along these lines:
7
8
 
8
9
  > **Prossimo passo:**
9
10
  > - `/clear`: per liberare il contesto
@@ -19,4 +20,4 @@ Resolving a ticket **ends the session**: one ticket per session is a hard rule (
19
20
 
20
21
  **This block is required whenever you point the user at the next ticket**, including in a **later follow-up** (after answering their questions, not only in the instant you resolve the ticket) **and in a capture's confirmation line** when it tells the user how to work the just-captured ticket. Never write "next: `/trailhead:work …`" without the `/clear` before it. **A capture is not a resolution**, so its confirmation needn't render the full labelled block: a compact, future-conditional one-liner is right, along the lines of *"to work it later: `/clear`, then `/trailhead:work 45`"* (in the user's conversation language), but the `/clear`-before-`/trailhead:work` order is non-negotiable there too. A bare `/trailhead:work 45` with no `/clear`, or a `/trailhead:work 45 (dopo /clear)` that hides `/clear` in a trailing parenthetical instead of leading with it, is the bug: `/clear` is always the first thing, on its own, before the next command.
21
22
 
22
- This ritual fires from every **Resolve** step (`build`, `bug`) and from **Mode 2**'s close (which resolves `decision` / `prototype` / `task` too). It does **not** fire after a `capture` (resolves nothing) or a parallel `research` batch (AFK, not session-ending).
23
+ This ritual fires from every **Resolve** step (`build`, `bug`) and from **Mode 2**'s close (which resolves `decision` / `prototype` / `task` too). It does **not** fire after a `capture` (resolves nothing, so no recap either) or a parallel `research` batch (AFK, not session-ending).
@@ -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
 
@@ -56,7 +56,7 @@ A repo can hold **more than one live map** at once: you park one to work another
56
56
  - **Claiming is unchanged**: the assignee is the claim, per ticket, regardless of which map it belongs to.
57
57
 
58
58
  ## Splitting a ticket
59
- A ticket is sized to one ~100K-token session. When a claimed ticket turns out too big, or fans into distinct pieces, **split it**: don't grind through an oversized ticket. **Splitting is never autonomous.** Whether it was asked for explicitly (`/trailhead:split`) or surfaced mid-work ("this is too big once I'm in it"), **stop and ask the user before creating anything**:
59
+ A ticket is sized to one ~100K-token session. When a claimed ticket turns out too big, or fans into distinct pieces, **split it**: don't grind through an oversized ticket. **Splitting is never autonomous.** Whether it was asked for explicitly (`/trailhead:split`) or surfaced mid-work ("this is too big once I'm in it"), **stop and ask the user before creating anything** (the split's shape, which children, is an advisory choice that **offers** the delegate option, per the injection recipe in `choices.md`):
60
60
  1. **Propose the split and wait for a go-ahead.** Present the children you would create (each one's title and why it's a distinct session-sized piece) and how the original's blockers would re-point, then **wait for the user's confirmation**. Create nothing until they say go: this is a stop-and-ask moment like the rest of the engine's, never the agent's own call. (An explicit `/trailhead:split` is the user asking *to* split, not a go-ahead on *how*: you still propose the decomposition and confirm it.)
61
61
  2. **Create the child tickets** (create-then-wire): each carries `Parent: <map>`, a `Split from: <original name+link>` line, its type label, the map's `trailhead:map-<n>` label + native sub-issue edge (like any ticket), and `trailhead:blocked` if it has an open blocker. **When children block each other** (B builds on A), wire B's blocker (both the `## Blocked by` line and the native dependency) at the **sibling child's real id (A)**, never at the split-origin: that original is being superseded and closed, so nothing may point at it as a live blocker. Describe A by role if you like, but *link its actual number*.
62
62
  3. **Supersede the original**: post a comment listing the children (`split into <names+links>`), label it **`trailhead:superseded`**, and **close it**. The children fully replace it.
@@ -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.