@marcomigozzi/trailhead 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +10 -6
- package/README.md +10 -6
- package/bin/lib/codex-projection.js +9 -6
- package/bin/lib/codex-projection.test.js +5 -1
- package/bin/trailhead.js +50 -5
- package/bin/trailhead.test.js +77 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -0
- package/plugins/trailhead/agents/trailhead-codebase-map.md +2 -0
- package/plugins/trailhead/agents/trailhead-debug.md +2 -0
- package/plugins/trailhead/agents/trailhead-executor.md +2 -0
- package/plugins/trailhead/agents/trailhead-fix.md +2 -0
- package/plugins/trailhead/agents/trailhead-plan.md +2 -0
- package/plugins/trailhead/agents/trailhead-research.md +2 -0
- package/plugins/trailhead/agents/trailhead-verify.md +2 -0
- package/plugins/trailhead/commands/auto.md +6 -0
- package/plugins/trailhead/commands/quick.md +1 -1
- package/plugins/trailhead/hooks/hooks.json +13 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +247 -0
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +49 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -0
- package/plugins/trailhead/skills/_shared/load-first.md +16 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +2 -1
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -12
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -9
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -10
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -9
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -10
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +18 -15
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
- 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" } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Load first, in order
|
|
2
|
+
|
|
3
|
+
The **shared-core load contract**, single-sourced here and referenced by every cluster `SKILL.md`: the dispatcher `trailhead/` and the five clusters `trailhead-chart` / `trailhead-work` / `trailhead-view` / `trailhead-capture` / `trailhead-manage`. Each cluster's own `## Load first, in order` points here in a line, then states its cluster-specific isolation posture; this file holds the part that is identical for all of them.
|
|
4
|
+
|
|
5
|
+
The paths below are written **relative to the cluster `SKILL.md` that pointed you here** (exactly as that `SKILL.md`'s own shared-core references are), so `../_shared/` resolves to this directory from any cluster.
|
|
6
|
+
|
|
7
|
+
Before doing anything, read the shared core, in order:
|
|
8
|
+
|
|
9
|
+
1. `../_shared/principles.md`: refer by name, result-oriented output, no em-dashes, git per conventions, one ticket per session.
|
|
10
|
+
2. `../_shared/ticket-language.md`: write all Issue prose and commit bodies in `config.ticket.language` (default `en`), independent of the chat language.
|
|
11
|
+
3. `../_shared/substrate.md`: the GitHub-Issues model: labels, the frontier query, the map / ticket / dashboard / whiteboard anatomy, and the base-command cookbook pointer.
|
|
12
|
+
4. `../_shared/session-handoff.md`: how every resolution closes (`/clear` first, then the next command).
|
|
13
|
+
5. `../_shared/configuration.md`: the three config layers and the load contract.
|
|
14
|
+
6. `../_shared/techniques.md`: the technique index and the subagent-type rule.
|
|
15
|
+
|
|
16
|
+
Load the **effective config** (per `../_shared/configuration.md`) at session start, from the map's project root (`.trailhead/config.json` plus the global file), before any isolation workspace is set up.
|
|
@@ -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
|
|
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
|
|
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.
|
|
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,6 +2,7 @@
|
|
|
2
2
|
Interrogate the human live, one question at a time, until the decision is unambiguous and its rationale is captured. This is human-in-the-loop: the decision is theirs, so never answer on their behalf, never assume an answer you haven't heard, never proceed on silence.
|
|
3
3
|
|
|
4
4
|
- **Map the decision as a tree.** Every choice branches into the choices that hang off it. The *frontier* is every question whose prerequisites are already settled: the ones you can ask now without guessing. A question whose answer depends on another still-open one isn't on the frontier yet; it arrives there the moment that answer lands. You don't ask the frontier in a batch: you pick one from it, and recompute after the answer.
|
|
5
|
+
- **Breadth before depth: enumerate the whole surface first.** When the subject is a space with many parts (mapping an app or a project's scope, not a single isolated choice), open by enumerating every functional area it implies (for a video player: media library, search, thumbnails, filters, genres, the player itself, ...) and get them all onto the record before deep-diving any one. Depth on the current thread is seductive, but the tree only ever grows the questions you actually raise, so an area you never name never enters it and stays silently missing. Fan out first (name every area), then converge each. The deliverable is coverage of the whole surface, not a deep answer on one corner of it.
|
|
5
6
|
- **One question at a time, with your pick.** Number the question, state it, and offer your recommended answer. Then stop and wait. Each answer reshapes the tree: settled decisions push the frontier outward. Recompute and ask the next. Grilling stays human-decides: the **grilled questions themselves never offer the delegate option** (a recommended answer is not the delegate option), because converging a decision is the human's. A separate meta-ask a call-site may put *before* grilling (e.g. the `build`/`quick` Discuss "do you want a grilling round?") is its own advisory choice and can offer delegate/defer per that call-site. **When a grilled decision is genuinely blocked on a prerequisite, offer defer in prose** (state it in words as part of the question), per the recipe in `../choices.md`.
|
|
6
7
|
- **Facts are your job, decisions are theirs.** When a question needs a fact from the environment (filesystem, code, tools), go find it yourself; never ask the human what you could look up. Only questions downstream of that lookup wait; ask the rest now.
|
|
7
8
|
- **Refuse vague and hedged answers.** "Good," "fast," "simple," "users," "later" are not answers: probe. *Fast how: sub-second, or handles large datasets? Users: the Customer or the operator?* Make the abstract concrete: "walk me through it," "give me an example," "what does that actually look like?"
|
|
@@ -10,5 +11,5 @@ Interrogate the human live, one question at a time, until the decision is unambi
|
|
|
10
11
|
- **Steelman and play devil's advocate.** State the strongest case for the option they're leaning away from. Attack the option they favor from its weakest seam. If it survives, the decision is stronger; if it cracks, you found the real question.
|
|
11
12
|
- **Detect and open up tradeoffs.** When "or / versus / torn between / which is better" appears, pause: name what each side optimizes for, what each sacrifices, which aligns with the goal, then recommend with reasoning. Skip for trivial or already-decided choices.
|
|
12
13
|
- **Play back your understanding** before closing each branch: restate the decision and its rationale in your words and have them confirm or correct.
|
|
13
|
-
- **Stop only when the frontier is empty**: every branch visited, nothing silently assumed, the choice unambiguous *and* the why recorded.
|
|
14
|
+
- **Stop only when the frontier is empty**: every branch visited, nothing silently assumed, the choice unambiguous *and* the why recorded. **For a multi-part space** (per the breadth bullet above) also require every functional area to have been enumerated and visited: an empty frontier reached while whole areas were never raised is a false finish, because the tree only ever grew the questions you raised. A single isolated choice has no areas to enumerate, so its empty frontier is a real finish.
|
|
14
15
|
|