@marcomigozzi/trailhead 0.4.0 → 0.5.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 +22 -15
- package/README.md +23 -16
- package/bin/lib/codex-projection.js +102 -60
- package/bin/lib/codex-projection.test.js +119 -17
- package/bin/trailhead.js +205 -51
- package/bin/trailhead.test.js +195 -15
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +8 -3
- package/plugins/trailhead/agents/trailhead-code-review.md +9 -0
- package/plugins/trailhead/agents/trailhead-codebase-map.md +9 -0
- package/plugins/trailhead/agents/trailhead-debug.md +9 -0
- package/plugins/trailhead/agents/trailhead-executor.md +9 -0
- package/plugins/trailhead/agents/trailhead-fix.md +9 -0
- package/plugins/trailhead/agents/trailhead-plan.md +9 -0
- package/plugins/trailhead/agents/trailhead-research.md +9 -0
- package/plugins/trailhead/commands/adopt.md +1 -1
- package/plugins/trailhead/commands/bug.md +1 -1
- package/plugins/trailhead/commands/config.md +1 -1
- package/plugins/trailhead/commands/dashboard.md +1 -1
- package/plugins/trailhead/commands/grill.md +1 -1
- package/plugins/trailhead/commands/idea.md +1 -1
- package/plugins/trailhead/commands/inbox.md +1 -1
- package/plugins/trailhead/commands/map.md +1 -1
- package/plugins/trailhead/commands/new.md +1 -1
- package/plugins/trailhead/commands/note.md +1 -1
- package/plugins/trailhead/commands/pause.md +1 -1
- package/plugins/trailhead/commands/quick.md +3 -1
- package/plugins/trailhead/commands/resume.md +1 -1
- package/plugins/trailhead/commands/seed.md +1 -1
- package/plugins/trailhead/commands/split.md +1 -1
- package/plugins/trailhead/commands/ticket.md +1 -1
- package/plugins/trailhead/commands/todo.md +1 -1
- package/plugins/trailhead/commands/update.md +1 -1
- package/plugins/trailhead/commands/whiteboard.md +1 -1
- package/plugins/trailhead/commands/work.md +1 -1
- package/plugins/trailhead/hooks/hooks.json +4 -0
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +1 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +176 -0
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +118 -0
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +19 -4
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +41 -1
- package/plugins/trailhead/skills/{trailhead/references/configuration.md → _shared/configuration-reference.md} +29 -9
- package/plugins/trailhead/skills/_shared/configuration.md +8 -0
- package/plugins/trailhead/skills/_shared/multi-map.md +27 -0
- package/plugins/trailhead/skills/_shared/out-of-scope.md +11 -0
- package/plugins/trailhead/skills/_shared/principles.md +8 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +22 -0
- package/plugins/trailhead/skills/_shared/substrate-commands.md +159 -0
- package/plugins/trailhead/skills/_shared/substrate.md +35 -0
- package/plugins/trailhead/skills/{trailhead/references → _shared}/teamwork.md +5 -4
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/acceptance-testing.md +3 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -0
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +21 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +39 -0
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/research.md +2 -1
- package/plugins/trailhead/skills/_shared/techniques.md +20 -0
- package/plugins/trailhead/skills/_shared/ticket-anatomy.md +32 -0
- package/plugins/trailhead/skills/_shared/ticket-language.md +3 -0
- package/plugins/trailhead/skills/trailhead/SKILL.md +26 -453
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +29 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-capture}/references/capture.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +52 -0
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +24 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-chart}/references/inbox.md +6 -6
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +30 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-manage}/references/updating.md +12 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +113 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +66 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +52 -0
- package/plugins/trailhead/skills/trailhead/references/techniques/code-review.md +0 -14
- package/plugins/trailhead/skills/trailhead/references/techniques/plan-review.md +0 -24
- package/plugins/trailhead/skills/trailhead/references/techniques/prototype.md +0 -18
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/domain-vocabulary.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/grilling.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/tdd.md +0 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trailhead-install-guard.js: PreToolUse(Bash) hook.
|
|
3
|
+
// Slopsquatting barrier. trailhead's executor/fix agents can install packages
|
|
4
|
+
// (npm / PyPI / crates.io / Go / RubyGems) while resolving a ticket, so a
|
|
5
|
+
// hallucinated or typosquatted package NAME could be fetched silently. This gate
|
|
6
|
+
// BLOCKS any public-registry install that names a package until the agent has
|
|
7
|
+
// vetted the name, then instructs it to verify legitimacy (typosquat similarity,
|
|
8
|
+
// package age, download counts, repo metadata) and checkpoint the human on a
|
|
9
|
+
// suspicious result (block-and-ask). A vetted install proceeds by re-running the
|
|
10
|
+
// command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
|
|
11
|
+
// Only a NAMED install is gated: lockfile/manifest installs (bare `npm install`,
|
|
12
|
+
// `npm ci`, `pip install -r`, `cargo build`, `go mod download`, `bundle install`)
|
|
13
|
+
// and local paths / URLs / VCS refs carry no typed name and pass through.
|
|
14
|
+
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
15
|
+
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
16
|
+
// Self-contained, no dependencies. Pure functions exported for tests; runs as a
|
|
17
|
+
// hook when executed directly.
|
|
18
|
+
|
|
19
|
+
const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
|
|
20
|
+
|
|
21
|
+
// Per-ecosystem: the binaries that own it, and the install verbs that fetch a
|
|
22
|
+
// named package. `add` covers pnpm/yarn/bun/uv/poetry/cargo; `install`/`i` npm
|
|
23
|
+
// and pip; `get`/`install` go.
|
|
24
|
+
const ECOSYSTEMS = {
|
|
25
|
+
npm: { bins: ['npm', 'pnpm', 'yarn', 'bun'], verbs: ['install', 'i', 'add'] },
|
|
26
|
+
pip: { bins: ['pip', 'pip3', 'pipx', 'uv', 'poetry'], verbs: ['install', 'add'] },
|
|
27
|
+
crates: { bins: ['cargo'], verbs: ['add', 'install'] },
|
|
28
|
+
go: { bins: ['go'], verbs: ['get', 'install'] },
|
|
29
|
+
gem: { bins: ['gem'], verbs: ['install'] },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Flags that consume a SEPARATE-TOKEN value (a file, url, or dir), so that value
|
|
33
|
+
// is never mistaken for a package name (e.g. `pip install -r requirements.txt`).
|
|
34
|
+
const VALUE_FLAGS = new Set([
|
|
35
|
+
'-r', '--requirement', '-c', '--constraint', '-e', '--editable',
|
|
36
|
+
'-i', '--index-url', '--extra-index-url', '-f', '--find-links',
|
|
37
|
+
'-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
|
|
38
|
+
'-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
|
|
39
|
+
'--no-binary', '--only-binary',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
function basename(p) {
|
|
43
|
+
const s = String(p).split('/');
|
|
44
|
+
return s[s.length - 1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A positional that is NOT a public-registry name: local path, url, vcs ref, or a
|
|
48
|
+
// file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
|
|
49
|
+
// module, so for go only a leading . / ~ disqualifies.
|
|
50
|
+
function isLocalOrUrl(t, ecosystem) {
|
|
51
|
+
if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
|
|
52
|
+
if (ecosystem === 'go') return false; // a bare module path is the name; gate it
|
|
53
|
+
if (t.includes('://')) return true;
|
|
54
|
+
if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
|
|
55
|
+
if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Best-effort bare name for the human-facing reason (detection does not depend on
|
|
60
|
+
// this being exact). Strips pip version specifiers / extras and npm @version.
|
|
61
|
+
function bareName(t) {
|
|
62
|
+
let n = t.split(/[=<>~!\[]/)[0]; // pip: pkg==1, pkg>=1, pkg[extra]
|
|
63
|
+
if (n.startsWith('@')) {
|
|
64
|
+
const at = n.indexOf('@', 1); // scoped @scope/pkg@1 → cut the version @
|
|
65
|
+
if (at !== -1) n = n.slice(0, at);
|
|
66
|
+
} else {
|
|
67
|
+
const at = n.indexOf('@'); // npm pkg@1.2.3
|
|
68
|
+
if (at !== -1) n = n.slice(0, at);
|
|
69
|
+
}
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Detect a public-registry install that NAMES at least one package, in a single
|
|
74
|
+
// shell segment. Returns { ecosystem, packages: [names] } or null.
|
|
75
|
+
function detectSegment(cmd) {
|
|
76
|
+
const toks = String(cmd).trim().split(/\s+/).filter(Boolean);
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
79
|
+
if (i >= toks.length) return null;
|
|
80
|
+
|
|
81
|
+
const bin = basename(toks[i]);
|
|
82
|
+
let ecosystem = null;
|
|
83
|
+
for (const [name, spec] of Object.entries(ECOSYSTEMS)) {
|
|
84
|
+
if (spec.bins.includes(bin)) { ecosystem = name; break; }
|
|
85
|
+
}
|
|
86
|
+
// `python -m pip install …` → treat as pip.
|
|
87
|
+
if (!ecosystem && /^python3?$/.test(bin)) {
|
|
88
|
+
if (toks[i + 1] === '-m' && /^pip3?$/.test(toks[i + 2] || '')) {
|
|
89
|
+
ecosystem = 'pip';
|
|
90
|
+
i += 2; // land on `pip`
|
|
91
|
+
} else {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!ecosystem) return null;
|
|
96
|
+
i++; // past the binary (or the `pip` peeled above)
|
|
97
|
+
|
|
98
|
+
// `uv pip install …` → peel the `pip`.
|
|
99
|
+
if (bin === 'uv' && toks[i] === 'pip') i++;
|
|
100
|
+
|
|
101
|
+
const verb = toks[i];
|
|
102
|
+
if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
|
|
103
|
+
i++;
|
|
104
|
+
|
|
105
|
+
const packages = [];
|
|
106
|
+
for (let j = i; j < toks.length; j++) {
|
|
107
|
+
const t = toks[j];
|
|
108
|
+
if (t.startsWith('-')) {
|
|
109
|
+
if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (isLocalOrUrl(t, ecosystem)) continue;
|
|
113
|
+
packages.push(bareName(t));
|
|
114
|
+
}
|
|
115
|
+
if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
|
|
116
|
+
return { ecosystem, packages };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Agents chain commands (`cd app && npm install foo`), so check every shell
|
|
120
|
+
// segment, not just the first. Split on the common separators (&& || ; | and
|
|
121
|
+
// newlines); a registry name never contains one, so a stray split is harmless.
|
|
122
|
+
// Returns the first segment's hit, or null.
|
|
123
|
+
function detectInstall(cmd) {
|
|
124
|
+
for (const seg of String(cmd).split(/&&|\|\||;|\n|\|/)) {
|
|
125
|
+
const hit = detectSegment(seg);
|
|
126
|
+
if (hit) return hit;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The agent's "I have vetted these" acknowledgement.
|
|
132
|
+
function isVerified(cmd) {
|
|
133
|
+
return SENTINEL.test(String(cmd));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function block(hit) {
|
|
137
|
+
const list = hit.packages.join(', ');
|
|
138
|
+
const reason =
|
|
139
|
+
`trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
|
|
140
|
+
'Guard against slopsquatting before installing: verify each package is legitimate, weighing ' +
|
|
141
|
+
'name / typosquat similarity to popular packages, package age / first-seen date, download counts / popularity, ' +
|
|
142
|
+
'and repository metadata (present and matching). ' +
|
|
143
|
+
'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
|
|
144
|
+
'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
|
|
145
|
+
process.stdout.write(JSON.stringify({ decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason }));
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function run(data) {
|
|
150
|
+
let cmd = '';
|
|
151
|
+
try {
|
|
152
|
+
cmd = (JSON.parse(data).tool_input || {}).command || '';
|
|
153
|
+
} catch {
|
|
154
|
+
process.exit(0); // unparseable input: nothing to gate
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (isVerified(cmd)) process.exit(0); // agent has vetted these
|
|
158
|
+
const hit = detectInstall(cmd);
|
|
159
|
+
if (hit) block(hit);
|
|
160
|
+
} catch {
|
|
161
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
162
|
+
}
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (require.main === module) {
|
|
167
|
+
let data = '';
|
|
168
|
+
const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends → give up
|
|
169
|
+
process.stdin.on('data', (c) => (data += c));
|
|
170
|
+
process.stdin.on('end', () => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
run(data);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { detectInstall, isVerified };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for trailhead-install-guard.js. Run: node trailhead-install-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-install-guard.js');
|
|
9
|
+
const { detectInstall, isVerified } = require('./trailhead-install-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_input JSON on stdin, capture {code, out}.
|
|
15
|
+
function runHook(command) {
|
|
16
|
+
const input = JSON.stringify({ tool_input: { command } });
|
|
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
|
+
// --- unit: detectInstall positives (a NAMED public-registry install) ---
|
|
26
|
+
const pos = [
|
|
27
|
+
['npm install lodash', 'npm', 'lodash'],
|
|
28
|
+
['npm i react', 'npm', 'react'],
|
|
29
|
+
['pnpm add vite', 'npm', 'vite'],
|
|
30
|
+
['yarn add left-pad', 'npm', 'left-pad'],
|
|
31
|
+
['bun add hono', 'npm', 'hono'],
|
|
32
|
+
['pip install requests', 'pip', 'requests'],
|
|
33
|
+
['pip3 install numpy', 'pip', 'numpy'],
|
|
34
|
+
['python -m pip install flask', 'pip', 'flask'],
|
|
35
|
+
['python3 -m pip install flask', 'pip', 'flask'],
|
|
36
|
+
['uv add httpx', 'pip', 'httpx'],
|
|
37
|
+
['uv pip install httpx', 'pip', 'httpx'],
|
|
38
|
+
['poetry add django', 'pip', 'django'],
|
|
39
|
+
['pipx install black', 'pip', 'black'],
|
|
40
|
+
['cargo add serde', 'crates', 'serde'],
|
|
41
|
+
['cargo install ripgrep', 'crates', 'ripgrep'],
|
|
42
|
+
['go get example.com/foo/bar', 'go', 'example.com/foo/bar'],
|
|
43
|
+
['go install golang.org/x/tools/cmd/stringer', 'go', 'golang.org/x/tools/cmd/stringer'],
|
|
44
|
+
['gem install rails', 'gem', 'rails'],
|
|
45
|
+
];
|
|
46
|
+
for (const [cmd, eco, name] of pos) {
|
|
47
|
+
const d = detectInstall(cmd);
|
|
48
|
+
ok(`detects: ${cmd}`, d && d.ecosystem === eco && d.packages.includes(name));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scoped and versioned names are still names (bare name reported).
|
|
52
|
+
ok('detects scoped npm name', (() => { const d = detectInstall('npm install @scope/pkg'); return d && d.packages[0] === '@scope/pkg'; })());
|
|
53
|
+
ok('strips npm @version', (() => { const d = detectInstall('npm install lodash@4.17.21'); return d && d.packages[0] === 'lodash'; })());
|
|
54
|
+
ok('strips pip == specifier', (() => { const d = detectInstall('pip install requests==2.0'); return d && d.packages[0] === 'requests'; })());
|
|
55
|
+
ok('strips pip extra', (() => { const d = detectInstall('pip install requests[security]'); return d && d.packages[0] === 'requests'; })());
|
|
56
|
+
ok('scoped versioned npm keeps scope', (() => { const d = detectInstall('npm install @scope/pkg@1.2.3'); return d && d.packages[0] === '@scope/pkg'; })());
|
|
57
|
+
|
|
58
|
+
// --- unit: detectInstall negatives (no typed registry name) ---
|
|
59
|
+
const neg = [
|
|
60
|
+
'npm install', // bare: from package.json/lock
|
|
61
|
+
'npm ci', // lockfile install
|
|
62
|
+
'pip install -r requirements.txt', // requirements file
|
|
63
|
+
'pip install --requirement req.txt',
|
|
64
|
+
'pip install -e .', // editable local
|
|
65
|
+
'npm install ./local-tarball.tgz', // local path
|
|
66
|
+
'npm install file:../pkg', // file: ref
|
|
67
|
+
'cargo build', // not an install verb
|
|
68
|
+
'go mod download', // not get/install
|
|
69
|
+
'bundle install', // unrecognised binary
|
|
70
|
+
'pip install git+https://github.com/x/y', // vcs url
|
|
71
|
+
'npm install --save-dev', // flag only, no name
|
|
72
|
+
'ls -la', // unrelated
|
|
73
|
+
'go build ./...', // not get/install
|
|
74
|
+
];
|
|
75
|
+
for (const cmd of neg) {
|
|
76
|
+
ok(`ignores: ${cmd}`, detectInstall(cmd) === null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `-r`'s value must not be read as a package even with a trailing name absent.
|
|
80
|
+
ok('consumes -r value, no phantom name', detectInstall('pip install -r requirements.txt') === null);
|
|
81
|
+
|
|
82
|
+
// Compound commands: an install in any segment is caught, not just the first.
|
|
83
|
+
ok('detects install after && chain', (() => { const d = detectInstall('cd app && npm install lodash'); return d && d.packages.includes('lodash'); })());
|
|
84
|
+
ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; pip install requests'); return d && d.packages.includes('requests'); })());
|
|
85
|
+
ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
|
|
86
|
+
ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
|
|
87
|
+
|
|
88
|
+
// --- unit: isVerified ---
|
|
89
|
+
ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
|
|
90
|
+
ok('no sentinel', !isVerified('npm install lodash'));
|
|
91
|
+
|
|
92
|
+
// --- end-to-end: block ---
|
|
93
|
+
const b1 = runHook('npm install lodash');
|
|
94
|
+
ok('blocks an unvetted named install (exit 2)', b1.code === 2 && /UNVETTED_PACKAGE_INSTALL/.test(b1.out));
|
|
95
|
+
ok('block names the package', /lodash/.test(b1.out) && /"decision":"block"/.test(b1.out));
|
|
96
|
+
const b2 = runHook('pip install some-obscure-pkg');
|
|
97
|
+
ok('blocks a pip named install (exit 2)', b2.code === 2 && /some-obscure-pkg/.test(b2.out));
|
|
98
|
+
|
|
99
|
+
// --- end-to-end: allow ---
|
|
100
|
+
const a1 = runHook('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash');
|
|
101
|
+
ok('sentinel allows a vetted install (exit 0)', a1.code === 0 && a1.out.trim() === '');
|
|
102
|
+
const a2 = runHook('npm install');
|
|
103
|
+
ok('bare install allowed (exit 0)', a2.code === 0 && a2.out.trim() === '');
|
|
104
|
+
const a3 = runHook('ls -la');
|
|
105
|
+
ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
|
|
106
|
+
const a4 = runHook('pip install -r requirements.txt');
|
|
107
|
+
ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
|
|
108
|
+
|
|
109
|
+
// --- end-to-end: unparseable stdin allowed ---
|
|
110
|
+
const u = (() => {
|
|
111
|
+
try {
|
|
112
|
+
const out = execFileSync('node', [HOOK], { input: 'not json' });
|
|
113
|
+
return { code: 0, out: out.toString() };
|
|
114
|
+
} catch (e) { return { code: e.status, out: (e.stdout || '').toString() }; }
|
|
115
|
+
})();
|
|
116
|
+
ok('unparseable stdin allowed (exit 0)', u.code === 0);
|
|
117
|
+
|
|
118
|
+
console.log(`✓ install-guard: ${passed} assertions passed`);
|
|
@@ -43,8 +43,17 @@ function ghIssueWrite(cmd) {
|
|
|
43
43
|
let i = 0;
|
|
44
44
|
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
45
45
|
if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
i++;
|
|
47
|
+
// Skip gh global flags before the subcommand, notably `-R`/`--repo <value>`
|
|
48
|
+
// (its separate-token value must be consumed too), so `gh -R o/r issue
|
|
49
|
+
// comment …` is still recognised as a write and gets scanned. Without this a
|
|
50
|
+
// secret could slip past simply by naming the repo up front.
|
|
51
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
52
|
+
if (/^(-R|--repo)$/.test(toks[i]) && !toks[i].includes('=')) i++;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
const sub = toks[i];
|
|
56
|
+
const verb = toks[i + 1];
|
|
48
57
|
if ((sub === 'issue' || sub === 'pr') && ['create', 'comment', 'edit', 'review'].includes(verb)) {
|
|
49
58
|
return sub;
|
|
50
59
|
}
|
|
@@ -53,10 +62,16 @@ function ghIssueWrite(cmd) {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
// For gh issue/pr, the body may live in a --body-file <path>; read it too.
|
|
65
|
+
// The path is quoted ("..."/'...') or bare. A bare path stops at whitespace AND
|
|
66
|
+
// at shell metacharacters (; | & ( ) < > `): in a compound command like
|
|
67
|
+
// `gh ... --body-file /tmp/x.md; echo done` the `;` abuts the path with no
|
|
68
|
+
// space, so a naive [^\s]+ would swallow it into the filename and the read would
|
|
69
|
+
// ENOENT, turning a clean write into a spurious could-not-scan (and, worse, a
|
|
70
|
+
// real body left unscanned). Quoted paths keep those characters verbatim.
|
|
56
71
|
function bodyFileText(cmd) {
|
|
57
|
-
const m = cmd.match(/(?:--body-file|(?<![-\w])-F)(?:=|\s+)(["']
|
|
72
|
+
const m = cmd.match(/(?:--body-file|(?<![-\w])-F)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"';|&()<>`]+))/);
|
|
58
73
|
if (!m) return '';
|
|
59
|
-
const path = m[2];
|
|
74
|
+
const path = m[1] || m[2] || m[3];
|
|
60
75
|
if (path === '-' || path.startsWith('<')) return ''; // stdin/heredoc already in the command string
|
|
61
76
|
return fs.readFileSync(path, 'utf8').slice(0, 1_000_000);
|
|
62
77
|
}
|
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
|
|
10
10
|
const HOOK = path.join(__dirname, 'trailhead-secret-guard.js');
|
|
11
|
-
const { scan, ghIssueWrite } = require('./trailhead-secret-guard.js');
|
|
11
|
+
const { scan, ghIssueWrite, bodyFileText } = require('./trailhead-secret-guard.js');
|
|
12
12
|
|
|
13
13
|
let passed = 0;
|
|
14
14
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
@@ -36,7 +36,13 @@ ok('scan passes clean prose', scan('tutto ok, 368 test verdi') === null);
|
|
|
36
36
|
ok('detects gh issue comment', ghIssueWrite('gh issue comment 5 --body "x"') === 'issue');
|
|
37
37
|
ok('detects gh pr create', ghIssueWrite('gh pr create --body "x"') === 'pr');
|
|
38
38
|
ok('detects gh api body=', ghIssueWrite('gh api repos/o/r/issues/5/comments -f body=x') === 'api');
|
|
39
|
+
ok('detects gh -R o/r issue comment (global flag before subcommand)',
|
|
40
|
+
ghIssueWrite('gh -R owner/repo issue comment 5 --body x') === 'issue');
|
|
41
|
+
ok('detects gh --repo=o/r pr create (attached global flag)',
|
|
42
|
+
ghIssueWrite('gh --repo=owner/repo pr create --body x') === 'pr');
|
|
39
43
|
ok('ignores gh issue view (read)', ghIssueWrite('gh issue view 5 --json body') === null);
|
|
44
|
+
ok('ignores gh -R o/r issue view (read, even with global flag)',
|
|
45
|
+
ghIssueWrite('gh -R owner/repo issue view 5 --json body') === null);
|
|
40
46
|
ok('ignores non-gh commands', ghIssueWrite('echo ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789xx') === null);
|
|
41
47
|
|
|
42
48
|
// --- end-to-end: block ---
|
|
@@ -53,6 +59,40 @@ const b3 = runHook(`gh issue comment 5 --body-file ${leaky}`);
|
|
|
53
59
|
ok('blocks a secret inside --body-file (exit 2)', b3.code === 2 && /AWS access key/.test(b3.out));
|
|
54
60
|
fs.unlinkSync(leaky);
|
|
55
61
|
|
|
62
|
+
// --- unit: bodyFileText() path extraction ---
|
|
63
|
+
// A bare path abutting a shell metacharacter in a compound command must NOT
|
|
64
|
+
// swallow the metacharacter into the filename (regression: `.md;` -> ENOENT).
|
|
65
|
+
{
|
|
66
|
+
const leak = path.join(os.tmpdir(), `sg-meta-${process.pid}.md`);
|
|
67
|
+
fs.writeFileSync(leak, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
|
|
68
|
+
ok('bare path stops at a trailing ; (compound command)',
|
|
69
|
+
/AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`gh issue edit 5 --body-file ${leak}; echo done`)));
|
|
70
|
+
ok('bare path stops at a trailing ) ',
|
|
71
|
+
/AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`(gh issue edit 5 --body-file ${leak})`)));
|
|
72
|
+
ok('bare path still works with a following && and space',
|
|
73
|
+
/AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`gh issue edit 5 --body-file ${leak} && echo ok`)));
|
|
74
|
+
// A quoted path keeps metacharacters verbatim (only the closing quote delimits).
|
|
75
|
+
const quoted = path.join(os.tmpdir(), `sg;meta-${process.pid}.md`);
|
|
76
|
+
fs.writeFileSync(quoted, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
|
|
77
|
+
ok('quoted path keeps an inner ; in the filename',
|
|
78
|
+
/AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`gh issue edit 5 --body-file "${quoted}"`)));
|
|
79
|
+
fs.unlinkSync(leak);
|
|
80
|
+
fs.unlinkSync(quoted);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// End-to-end: the compound-command shape that produced the spurious advisory
|
|
84
|
+
// must now scan and BLOCK the real secret in the body.
|
|
85
|
+
const leaky2 = path.join(os.tmpdir(), `sg-leaky2-${process.pid}.md`);
|
|
86
|
+
fs.writeFileSync(leaky2, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
|
|
87
|
+
const b4 = runHook(`gh issue edit 5 --body-file ${leaky2}; echo done`);
|
|
88
|
+
ok('blocks a secret in --body-file even in a compound command (exit 2)',
|
|
89
|
+
b4.code === 2 && /AWS access key/.test(b4.out));
|
|
90
|
+
fs.unlinkSync(leaky2);
|
|
91
|
+
|
|
92
|
+
// A global flag before the subcommand must not let a secret slip past unscanned.
|
|
93
|
+
const b5 = runHook('gh -R owner/repo issue comment 5 --body "t ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789xx"');
|
|
94
|
+
ok('blocks an inline secret behind gh -R (exit 2)', b5.code === 2 && /"decision":"block"/.test(b5.out));
|
|
95
|
+
|
|
56
96
|
// --- end-to-end: allow ---
|
|
57
97
|
const a1 = runHook('gh issue comment 5 --body "tutto ok, 368 test verdi"');
|
|
58
98
|
ok('allows a clean write (exit 0, no output)', a1.code === 0 && a1.out.trim() === '');
|
|
@@ -4,16 +4,18 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
|
|
|
4
4
|
|
|
5
5
|
| Key | Values (default **bold**) | Effect |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `models.{plan,execute,research,review,debug}` | a model id (**
|
|
7
|
+
| `models.{plan,execute,research,review,debug}` | a model id (**Balanced-profile default**) | model for that activity; an unset key resolves to its default **Balanced** tier, see note |
|
|
8
8
|
| `models.codex.{plan,execute,research,review,debug}` | an OpenAI model id (**inherit session**) | per-activity subagent model on **Codex** (OpenAI ids), the honoured namespace there; independent of `models.*` (Claude ids); projected into `~/.codex/agents/trailhead-*.toml` and dispatched by `agent_type`. See note |
|
|
9
9
|
| `ticket.language` | an ISO 639-1 code (**`en`**) | the language trailhead **writes** its GitHub prose in (ticket titles/bodies, engine comments, map body & commit descriptions), see note. Independent of the language the agent converses in |
|
|
10
|
-
| `design` | **`disk`** \| `claude.ai/design` | mode for UI mockups (**Prototype**). `disk` = throwaway local HTML; `claude.ai/design` =
|
|
10
|
+
| `design` | **`disk`** \| `claude.ai/design` \| `stitch` | mode for UI mockups (**Prototype**). `disk` = throwaway local HTML (any host); `claude.ai/design` = create/edit the mockup on claude.ai/design via Anthropic's **official Claude Design MCP** (`claude mcp add --scope user --transport http claude-design https://api.anthropic.com/v1/design/mcp`, auth `/design-login`), **Claude only**; `stitch` = create/edit the mockup via the **Google Stitch MCP** (`claude mcp add --scope user --transport http stitch https://stitch.googleapis.com/mcp`), the **cross-host** hosted option (Claude Code **and** Codex). A hosted mode's projectId is created/reused at execution and cached in `design.project`; which surface it uses is `design.surface`. If the configured mode's MCP isn't connected, Prototype offers a one-time install before falling back (see Prototype). **On Codex** `claude.ai/design` isn't available, so Prototype **offers to switch to `stitch`** (cross-host) before degrading to `disk`; `stitch` and `disk` run on Codex unchanged |
|
|
11
|
+
| `design.surface` | **`canvas`** \| `design-system` | under a hosted `design` mode (`claude.ai/design` or `stitch`), which surface the mockup lives on. `canvas` = a single surface of screens to react to (under `claude.ai/design`: a `/design` **Artifact** or a hand-made web-app project, **not** MCP-created; under `stitch`: a Stitch project), editable visually and exportable as a live prototype. `design-system` = push components into a shared design-system project (a `PROJECT_TYPE_DESIGN_SYSTEM` synced via DesignSync `/design-sync`, or a Stitch design system), for a shared component library. **Back-compat:** unset, a cached `design.project` infers `design-system` (that's what the key meant before this key existed); with no cached project, `canvas`. See Prototype |
|
|
11
12
|
| `design.approval` | **`explicit`** \| `auto` | must a mockup be explicitly approved before the build proceeds to UI code? |
|
|
12
13
|
| `tdd` | **`seams`** \| `on` \| `off` | how the `build` engine tests |
|
|
13
14
|
| `acceptance.browser` | **`auto`** \| `on` \| `off` | how **Acceptance testing** runs |
|
|
14
15
|
| `testing.webapp` / `testing.url` | bool / url | is it browser-drivable, and where |
|
|
15
16
|
| `plan_review` | **`off`** \| `on` \| CLI list (`gemini,codex`) | send `build` PLANs to external AI CLIs for review (**Cross-AI plan review**) |
|
|
16
17
|
| `plan_review.rounds` | integer (**`2`**) | max converge-and-re-review rounds |
|
|
18
|
+
| `code_review.rounds` | integer (**`2`**) | max fix-and-re-review rounds in **Code review** before the boundary HITL checkpoint |
|
|
17
19
|
|
|
18
20
|
**Models.** Each key targets a specific activity, so you can send the heavy reasoning to a strong model and the cheap legwork to a fast one:
|
|
19
21
|
- `plan` / `execute`: the `build`/`bug` engine's **Plan** and **Execute** steps (see below).
|
|
@@ -30,20 +32,36 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
|
|
|
30
32
|
|
|
31
33
|
**Inline update check (hookless hosts).** On a host without a hook bus (the descriptor's `hookBus == 'none'`) the `trailhead-check-update.js` SessionStart hook and the statusline flag are both absent, so the engine does the check **inline at session start**, co-located with the models-collapse notice above. It is lightweight and degrades silently: read the shared cache (`$XDG_CACHE_HOME/trailhead/update-check.json`, the same file the hook writes on Claude Code) and, if it reports an update, surface a one-line notice pointing at `/trailhead:update` (the pure `updateNotice(host, cache)` helper in `bin/lib/host-descriptor.js` is the canonical wording, and returns null on a host that already notifies via the hook, so it never double-fires); if the cache is absent or stale (older than a few hours), do a minimal throttled compare of the installed `VERSION` against the latest (npm registry or the GitHub release, per install channel) and rewrite the cache. Never block or fail the session on a network or parse error. `/trailhead:update` stays the on-demand installer.
|
|
32
34
|
|
|
33
|
-
If a value is unset,
|
|
35
|
+
If a value is unset, it resolves to its **Balanced**-profile default for that activity (the default profile: a project with no config still gets the Balanced tier per key, resolved to a concrete id at load time, strong → `claude-opus-4-8`, standard → Sonnet-class, fast → Haiku-class). To use the session model instead of the Balanced default, set the key explicitly to `inherit session`. Never hard-fail on an unknown id: warn and inherit the session model.
|
|
34
36
|
|
|
35
37
|
**Everything else runs in the main session on its own model, there is no key for it:** charting, grilling, naming the destination, framing and **writing the tickets**, graduating fog, and map/decision operations. That's deliberate: it's HITL/orchestration work (you reason *with* the user), so the model that writes a ticket is the one grilling you; it can't fan out to a subagent. Practical upshot: **run a charting session (`/trailhead:new`/`:adopt`/`:ticket`) on your strong model**. The `models.*` keys above govern only the fan-out activities, not the quality of the tickets themselves. **Store a full model id** (e.g. `claude-opus-5`, `claude-fable-5`), never a bare family alias like `opus`; a family now spans several live versions, so an alias can't say which one. **`models.plan` and `models.execute` are always distinct choices**: never collapse them into one shared model pick, even when the user would pick the same id for both.
|
|
36
38
|
|
|
39
|
+
**Model-tier profiles.** Three profile seeds for the guided setup's Models step (§3), one philosophy: *smart at every level* (research is never the strongest tier; execute and debug are never the fastest). Choosing a profile **applies all five keys directly** (no further per-key questions); the separate **Manual** path is where `plan`/`execute`/the rest are picked one by one. **The default profile is `Balanced`** (the **(consigliato)** one): a project with no config resolves its five model keys as Balanced. **High** and **Low** are the alternatives, and **Manual** lets you set each key yourself (including `inherit session` per key). `fix` follows `execute` and codebase-map follows `research`, so neither needs its own profile entry.
|
|
40
|
+
|
|
41
|
+
| profile | plan | execute | research | review | debug |
|
|
42
|
+
|---|---|---|---|---|---|
|
|
43
|
+
| **High** | strong | strong | standard | strong | strong |
|
|
44
|
+
| **Balanced** | strong | standard | fast | strong | strong |
|
|
45
|
+
| **Low** | standard | standard | fast | fast | standard |
|
|
46
|
+
|
|
47
|
+
**Tier-class -> id**, resolved from the lineup actually reachable at setup time (verify, don't trust these verbatim):
|
|
48
|
+
- **Claude** (`models.*`, ids only, no effort): strong = Opus-class, resolved to **`claude-opus-4-8`** by default (the newer `claude-opus-5` stays offered in the per-key menus, but is not the default pick) · standard = Sonnet-class · fast = Haiku-class.
|
|
49
|
+
- **Codex** (`models.codex.*`, `{model, effort}`): strong = `gpt-5.6-sol` @ `high` (debug on **High** may be `xhigh`) · standard = `gpt-5.6-terra` @ `medium` · fast = `gpt-5.6-luna` @ `low`.
|
|
50
|
+
|
|
37
51
|
**Ticket language.** `ticket.language` (ISO 639-1, default `en`) sets the language trailhead **writes** in on the Issues: ticket titles and bodies, engine comments (DISCUSS/PLAN/VERIFY/OPTIONS/REPRO/DIAGNOSIS/PAUSED/resolution), the map body sections, and commit message **descriptions**. It's fully decoupled from the language the agent *converses* in (its session/global setting); setting `ticket.language` never changes how the agent talks to the user. **Untouched by it:** code and identifiers, the fixed `trailhead:*` label names, config keys/values, and the conventional-commit type prefix (`feat:`/`fix:` stay English). The skill's own source docs (SKILL.md, `references/`) are not project prose and stay English.
|
|
38
52
|
|
|
39
53
|
**tdd.** `seams` (default) = TDD at critical seams only; `on` = TDD for all behaviour with a definable input→output; `off` = tests after, or none for throwaway. The `build` engine's Plan/Execute honour this.
|
|
40
54
|
|
|
41
|
-
**acceptance.browser.** `auto` (default) = drive the browser when `testing.webapp` + `testing.url` are set,
|
|
55
|
+
**acceptance.browser.** `auto` (default) = drive the browser when `testing.webapp` + `testing.url` are set; when `testing.webapp` is set but `testing.url` is unset, first try to auto-detect the target URL (running dev server, package scripts, dev-server/framework config, project config) and propose it, driving against the accepted URL, and fall back to a guided UAT checklist only when none can be determined; `on` = always attempt browser-driving, auto-detecting the URL the same way when `testing.url` is unset; `off` = always hand the human a UAT checklist. **`off` disables *browser-driving*, not acceptance**: a user-facing change still gets a guided UAT checklist (see the Acceptance testing technique). The Verify step honours this.
|
|
56
|
+
|
|
57
|
+
**design.surface.** Under `design: claude.ai/design`, which Claude Design surface the mockup lives on. `canvas` (default) = a one-off screen to react to, edited visually and exportable as a live prototype; under `claude.ai/design` it is **not** MCP-driven (no MCP creates a regular canvas project): it's the built-in `/design` skill published as an **Artifact**, or a claude.ai/design project made by hand in the web app whose `/p/<id>` URL you paste. `design-system` = a `PROJECT_TYPE_DESIGN_SYSTEM` project driven by DesignSync (the `/design-sync` slice of the official Claude Design MCP), for a shared component library kept in sync. Unset, it infers from `design.project`: a cached project (which, before this key, was always a design system) means `design-system`; no cached project means `canvas`. The Prototype technique honours this; when the **`design-system`** surface's MCP isn't connected it **offers a one-time install** (`claude mcp add ... claude-design`, then `/design-login`) before falling to disk (the `canvas` surface needs no MCP, so no install offer). Under `design: stitch` the same key applies: `canvas` = a Stitch project with generated screens, `design-system` = a Stitch design system; the install offer and disk fallback work the same way (no intra-provider chain, the Stitch MCP either connects or falls to disk). Ignored under `design: disk`.
|
|
42
58
|
|
|
43
59
|
**design.approval.** `explicit` (default) = a UI mockup must be **explicitly approved by the user** before the `build` engine writes any real UI code; surface the mockup and wait for a clear go-ahead. `auto` = surface the mockup and proceed without blocking on a confirmation (the user can still object). The Prototype technique honours this.
|
|
44
60
|
|
|
45
61
|
**plan_review.** `off` (default) = build PLANs are not externally reviewed; `on` = review with every external AI CLI detected on the PATH; a comma list (`gemini,codex`) = only those. Needs the named CLIs installed; if none are available it's skipped, never failed. The `build` engine's Plan step runs **Cross-AI plan review** when this is on.
|
|
46
62
|
|
|
63
|
+
**code_review.rounds.** Integer, default 2. The max fix-and-re-review rounds the **Code review** technique spends chasing **Critical** (blocking) findings during the `build`/`bug` Verify step. When the budget is exhausted with Critical findings still open, the technique never resolves or continues silently: it surfaces an explicit HITL checkpoint (run another 2 rounds vs proceed with the blockers recorded). Mirrors `plan_review.rounds`.
|
|
64
|
+
|
|
47
65
|
Example `config.json` (same shape for the project `.trailhead/config.json` and the global `~/.claude/trailhead/config.json`):
|
|
48
66
|
```json
|
|
49
67
|
{ "models": { "plan": "claude-opus-4-8", "execute": "claude-sonnet-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
|
|
@@ -57,13 +75,15 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
|
|
|
57
75
|
|
|
58
76
|
1. **Scope**: 🌍 Global default (`~/.claude/trailhead/config.json`) · 📁 This project (`.trailhead/config.json` at the repo root). Skip to global outside a repo.
|
|
59
77
|
2. **🌐 Ticket language**: the language trailhead writes its GitHub prose & commit descriptions in (`ticket.language`, ISO 639-1). Offer 🇬🇧 English (`en`, default) · 🇮🇹 Italiano (`it`) · 🇪🇸 Español (`es`) · 🇫🇷 Français (`fr`) · 🇩🇪 Deutsch (`de`) · 🇵🇹 Português (`pt`), plus a free-code option for any other ISO 639-1 code. Note in the prompt that this is *not* the language the agent converses in.
|
|
60
|
-
3. **🧠 Models**:
|
|
61
|
-
- **
|
|
62
|
-
- **
|
|
63
|
-
|
|
78
|
+
3. **🧠 Models**: a **profile** (Balanced by default) applied whole, or **Manual** for the per-key questions.
|
|
79
|
+
- **🎚️ Profile, asked first (and often the only Models question).** Present one `AskUserQuestion` menu: 🏔️ **High** · ⚖️ **Balanced (consigliato)** · 🍃 **Low** · ✍️ **Manual (specify each model)**. **On a project with no existing config, pre-select ⚖️ Balanced** (the default profile); when a config already exists, pre-select the profile its keys match (or **Manual** when they match none). **On High / Balanced / Low this is terminal:** seed the five model keys from the **Model-tier profiles** table above, resolving each tier-class to a concrete id **at setup time** (verify the lineup, don't trust ids verbatim), **write them, and do NOT ask the per-key questions**. Only **Manual** proceeds to the per-key questions below (where any key may be left at `inherit session`). **Host branch:** on **Claude** the profile seeds `models.*` with tier-class ids; on **Codex** it seeds `models.codex.*` as `{model, effort}` (effort is the Codex-only face), and after applying keep the **re-run the installer** instruction from the Codex per-key path below so the pins are re-projected into `~/.codex/agents/trailhead-*.toml`.
|
|
80
|
+
- **The per-key questions (Manual path only): always two separate questions, one for `plan` and one for `execute`; never merge them into a single model pick.** Ask each as its own `AskUserQuestion` menu (current value pre-selected), and offer the **same versioned lineup** to both. Each option is a **full model id with its version**; never a bare family alias (`opus`/`sonnet`/`haiku`), which can't say *which* live version. Build the list from the models actually reachable **at setup time** (verify, don't trust this file verbatim), offering **at least the two most recent versions per family**, e.g. `claude-fable-5`, `claude-opus-5` / `claude-opus-4-8`, `claude-sonnet-5` / `claude-sonnet-4-8`, `claude-haiku-4-5`, plus **inherit session** (the no-override option, the session model). If that lineup exceeds the menu's option cap, drill **family → version** or include a free-id "Other" so every recent version stays reachable. The rarer `research`/`review`/`debug` sit behind an "Advanced" option, each its own separate choice, same versioned rule.
|
|
81
|
+
- **Anti-pattern, never do this (in the Manual path):** a single screen whose options are *combined* plan+execute presets, e.g. `🧠 Plan 4.8 / Exec Sonnet 5`, `🚀 Tutto Opus 5`, `Plan Opus 5 / Exec Sonnet 5`. That bundles the two decisions into one pick. Forbidden. In Manual, Plan is asked, answered, and only then is Execute asked, as a second menu: two questions, two answers, always. (A **named tier profile** applied whole is **not** this anti-pattern: it is a coherent per-key tier assignment chosen deliberately, not an ad-hoc plan+execute combo, which is why picking a profile is terminal.)
|
|
82
|
+
- **On Codex (host-detected), the Manual path asks `models.codex.*` instead of the Claude lineup.** Same discipline: `plan` and `execute` as two separate `AskUserQuestion` menus, never merged; the rarer `research`/`review`/`debug` behind Advanced, each its own choice. Offer OpenAI model ids verified reachable at setup time (do not trust this list verbatim): `gpt-5.6-sol` (flagship) / `gpt-5.6-terra` (balanced) / `gpt-5.6-luna` (fast), plus **inherit session** (default). Avoid the retiring `gpt-5.4` / `gpt-5.4-mini` and the deprecated `gpt-5.3-codex`. Write the answers to `models.codex.*`; the Claude `models.*` lineup is not asked on Codex (those ids cannot run there). After writing, tell the user to **re-run the installer** (the same `--host=codex` install command) so the pins are re-projected into `~/.codex/agents/trailhead-*.toml`: the menu updates `.trailhead/config.json`, the installer is what regenerates the TOMLs and enables `features.multi_agent_v2`.
|
|
83
|
+
4. **🎨 Design**: 💾 Local disk · 🖼️ claude.ai/design (Anthropic's official Claude Design MCP, Claude only) · 🧵 Stitch (Google Stitch MCP, cross-host: Claude Code + Codex). *Mode only, don't ask for a URL here; the concrete project is created or reused at execution time and its projectId cached in `design.project` (see Prototype).* If claude.ai/design or Stitch is picked, ask the **🖼️ Design surface**: 🖌️ Canvas (a single project of screens to react to; default; **who makes it:** on claude.ai/design you do, the `/design` Artifact or a hand-made web-app project, not MCP-driven; on Stitch the MCP generates the screens) · 🧩 Design system (push components into a shared design-system project, for a shared library; **the MCP creates and syncs it**) → `design.surface`. Then **✅ Mockup approval**: 🖐️ Explicit (wait for a go-ahead before UI code) · ⏩ Auto (proceed without blocking).
|
|
64
84
|
5. **🧪 TDD**: 🎯 Seams only · ✅ On (all definable behaviour) · ⛔ Off.
|
|
65
85
|
6. **🖥️ Acceptance testing**: 🤖 Auto · ▶️ Always browser · 🙋 Guided UAT only. If browser is enabled, ask **🔗 base URL** and confirm it's a web app.
|
|
66
|
-
7. **🧑⚖️ Plan review**: **before presenting this menu, first probe the PATH for known external AI CLIs** (
|
|
86
|
+
7. **🧑⚖️ Plan review**: **before presenting this menu, first probe the PATH for known external AI CLIs** (probe **every** CLI in the Cross-AI plan review roster, do not shorten the list: `claude`, `gemini`, `codex`, `qwen`, `kimi`, `agy`, `opencode`, `cursor-agent`, `coderabbit`, plus any OpenAI-compatible local server configured; `kimi` counts only when `kimi --help` mentions `--output-format`, per the technique): `command -v` each one, then build the menu from what was actually found. **Exclude the current host's own CLI** (never offer `codex` when running on Codex, nor `claude` when running on Claude Code): a session reviewing a plan with its own model is not cross-AI, so the host's own CLI must never appear as an option. Never present this step with a hard-coded two-option list; the options are computed from the probe:
|
|
67
87
|
- **No CLI found**: offer only ⛔ Off, and say in the prompt that no external AI CLI was detected on the PATH (so the other choices would do nothing).
|
|
68
88
|
- **One CLI found**: ⛔ Off · 🤝 Use `<name>`. (With a single CLI, "all" and "pick specific" collapse to the same thing, so don't show both.)
|
|
69
89
|
- **Two or more found**: ⛔ Off · 🤝 All detected (`<name>`, `<name>`, …) · 🎯 Pick specific. Always show all three; never drop *Pick specific*. On *Pick specific*, ask **which CLIs** as a **multi-select** listing each detected CLI by name (never a free-text field). List the actual names found by the probe, not the placeholder examples from this file.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Configuration
|
|
2
|
+
|
|
3
|
+
Three layers, **nearest wins**, key by key; a key unset at one layer inherits the next:
|
|
4
|
+
- **Project**: a `.trailhead/config.json` file at the repo root: overrides for this project, committed so a team shares one config. **Config never lives in the map issue**: the map is an index of decisions, while config is the user's to change at any time, so it stays in a plain file (and never asserted into the map by the agent).
|
|
5
|
+
- **Global**: `~/.claude/trailhead/config.json` (honour `$CLAUDE_CONFIG_DIR`): your standing defaults across every project.
|
|
6
|
+
- **Defaults**: the built-in values documented in `configuration-reference.md`.
|
|
7
|
+
|
|
8
|
+
Load the effective config at the start of a work session, **from the map's project root** (`.trailhead/config.json` at the superproject repo root) plus the global file, **before** any isolation workspace is set up. **Config is a property of the project/map, not the working directory**: an `isolation: worktree`/`clone` workspace **inherits** the config loaded here and must **never re-resolve it from the isolated copy's own root**, because a per-ticket clone (a submodule clone especially) has no `.trailhead/` of its own and would silently fall back to defaults (this is exactly how a `design: claude.ai/design` project ends up wrongly using `disk` in a clone). Config **writes** too (e.g. caching `design.project`) target the project's `.trailhead/config.json` in the original checkout, never the clone. **The main session orchestrates and holds the HITL moments** (the `build`/`bug` Discuss's "stop and ask", Verify's acceptance/UAT); **every model key runs as a subagent** (`plan`, `execute` (an *executor subagent* implementing with atomic commits on `models.execute`), `research`, `review`, `debug`) so the whole per-activity model split applies in one session whatever model it runs on. Run Execute inline only when `models.execute` is unset or equals the session model. **On a subagent-less host** (`subagentToolkit == none`) there is nothing to fan out into: every activity runs inline. **Separately, on a host that can't honour the model keys** (`honorsModelKeys == false`, e.g. Codex, whose subagents run on its own OpenAI models) every `models.*` key collapses to the single session model even though the activities still fan out; when any `models.*` is set there the engine surfaces a **one-time** session-start notice that the split is not taking effect (the keys stay in `.trailhead/config.json` for when the project next runs on Claude Code). On Codex the per-activity split is available through a parallel `models.codex.*` namespace (OpenAI model ids), the honoured namespace there (projected into per-technique `~/.codex/agents/*.toml` and dispatched at runtime by `agent_type`), while `models.*` (Claude ids) still collapse to the single session model; the collapse notice points at `models.codex.*` and goes silent once it is set. A host without a hook bus (`hookBus == none`) also runs a lightweight inline update check at session start (no SessionStart hook, no statusline). See `configuration-reference.md`. The **keys, their values, the model/tdd/acceptance semantics, and the guided menu setup live in `configuration-reference.md`**: read it when running `/trailhead:config` or when you need a key's exact meaning. `/trailhead:config` with no args runs the guided setup; `config get` prints the merged config; `config set <key> <value>` writes one key.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
## Multiple maps on one repo (parking & concurrency)
|
|
2
|
+
|
|
3
|
+
By default a repo has **one live map** and the frontier is the whole repo (as everywhere above). But you can **park a map to work another**, and a teammate can chart a second map on the same repo and work it concurrently. When more than one `trailhead:map` is open, each map carries **its own frontier**, kept separate by two mechanisms wired together (the same "one fact, two views" pattern as a blocker's native edge + label):
|
|
4
|
+
|
|
5
|
+
- **Native sub-issue**: every ticket is a **sub-issue of its map** (`gh api --method POST repos/{owner}/{repo}/issues/<map>/sub_issues -F sub_issue_id=<ticket .id>`). This is the structure GitHub renders (the map shows its children + a progress bar); it carries the `Parent:` line's meaning as a real edge.
|
|
6
|
+
- **Per-map label `trailhead:map-<n>`** (n = the map issue's own number): on **every one of its tickets** (not on the map issue itself, which is identified by its number, and would only be redundantly "a member of itself"). This is the **queryable key** that scopes the frontier to one query, because native sub-issues aren't cheaply filterable in a single search (exactly as with `blocked_by`), so the label carries the query.
|
|
7
|
+
|
|
8
|
+
Both are set at chart and on every ticket **in the same pass** (create the ticket → set its map parent → add its map label), so they can't drift.
|
|
9
|
+
|
|
10
|
+
**Scoped frontier** (one query, per map):
|
|
11
|
+
```bash
|
|
12
|
+
gh issue list --label "trailhead:ticket" --label "trailhead:map-<n>" --state open \
|
|
13
|
+
--search "no:assignee -label:trailhead:blocked -label:trailhead:unverified"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Active map.** Which map a session works is the **active map**: a `.trailhead/active-map` marker at the working root (one line `#<n>`, **gitignored**, per-session local state like `session-ticket`, never committed). `/trailhead:work` and `/trailhead:map` default to it; naming a map/ticket explicitly always overrides. Each checkout/clone (so each teammate) has its own.
|
|
17
|
+
|
|
18
|
+
**Park & switch** = **pause** the in-flight ticket (`/trailhead:pause`, a `PAUSED` checkpoint), then point the active map at the other's number. Nothing else to save: every map lives on the Issues, durable. Resume later with `/trailhead:resume`, or just `/trailhead:work` once that map is active again.
|
|
19
|
+
|
|
20
|
+
**Discovery.** `gh issue list --label "trailhead:map" --state open` enumerates the live maps. `/trailhead:map` with **no active map and more than one open** lists them and asks which; `/trailhead:map <n>` shows that one and makes it active.
|
|
21
|
+
|
|
22
|
+
**Pinning with several maps.** No map is ever pinned. The 3 pinned slots are the permanent repo anchors: **dashboard + codebase + conventions**. Every open map is found via the `trailhead:map` label and listed in the pinned [dashboard](../trailhead-view/SKILL.md#render-the-dashboard-dashboard), so all of them stay reachable regardless of how many are live, without contending for a slot.
|
|
23
|
+
|
|
24
|
+
**Team.** A teammate charts a new map → it gets its own `trailhead:map-<m>` + sub-issue parentage → their sessions scope to it. The two frontiers **never mix** (distinct labels). Per-ticket **claim/collision** rules are unchanged (the assignee is still the claim). Two maps can still touch the **same code**: the `Scope:` line + `isolation:` serialisation from [Working as a team](teamwork.md#working-as-a-team) now apply **across maps too**, not just within one.
|
|
25
|
+
|
|
26
|
+
**One-map compatibility.** With a single open map the frontier stays **repo-wide** (minus `trailhead:whiteboard` tickets, which carry their own frontier, see [The whiteboard](../trailhead-view/SKILL.md#render-the-whiteboard-whiteboard)); the map label + sub-issue are still applied at chart, so concurrency is ready the moment a second map appears. An older map charted before this (tickets lacking the label) needs a one-time **backfill** (add `trailhead:map-<n>` to its open tickets, set their sub-issue parent) only if you add a second map on that repo.
|
|
27
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Out of scope vs deferred
|
|
2
|
+
|
|
3
|
+
The full test and re-routing protocol for the map's *Out of scope* section. SKILL.md → **Out of scope** keeps the core rule and a one-line test; read this file when you are about to file something under *Out of scope*, when re-routing a deferred line, or when running the inbox sweep.
|
|
4
|
+
|
|
5
|
+
## Out of scope vs deferred (ask, never bury)
|
|
6
|
+
*Out of scope* is for work ruled beyond the destination **for good**: this effort never does it. It is **not** a parking lot for work that *will* happen but hangs on something **outside this map**, a feature, function, or milestone not ticketed here (e.g. a billing / *bolletta* module that lives elsewhere or comes later, and the items that wait on it). Before filing anything under *Out of scope*, apply the test: **will this actually get done once X exists?** **The tells of a deferred line (any one is enough):** it **names a gate or trigger** (*gated by X*, *quando X*, *once X exists*, *dipende da X / waits on X*); it carries a **this-only scope qualifier** that rules it out *here* but not for good (*per questo slice*, *for now*, *in this milestone*, *not in this map*), which means wanted-later, not rejected; it's a **feature the product will plausibly still want** (a later milestone, another map); or **an existing `trailhead:seed` is gated on it** (some seed's trigger / `## Blocked by` names this line), which makes it a **live gate by definition**. Any one of these is deferred: never wave it through as "genuinely beyond the destination". Only work **decided against for the product** (won't ever be built, wrong approach, explicitly cut, with **no** "for this slice / for now" qualifier) is truly out of scope. **A line that is the gate of a live seed can never be "ruled out"**: finding one means the cluster is mis-wired (the gate was left behind when its dependents became seeds), so re-route the gate too (see below), or those seeds' triggers dangle. If yes, it isn't out of scope, it's **deferred**, so don't close-and-forget it. **Ask the user how to capture it, and which map it belongs to** (this map, or a future / other map); **never decide silently, and every move out of *Out of scope* is user-confirmed** (you propose, the user picks the tier and the map). Route along the **capture spectrum**: a **`seed`** gated on its trigger (tie it to what it waits on, e.g. *when the bolletta exists*), an **`idea`** to revisit, or a **`todo`** if it's already defined work to do now. **If it belongs to a future map** (defined work wanted for the product, but beyond this effort's arc), don't fabricate a map that isn't charted yet, but **do capture it as a real artifact, asking the user which**: a **`seed`** (a gated todo, a tracked issue that survives the map, is queryable by label, and can be the native `blocked_by` of its dependents) or an **`idea`** (a fog line, lighter, for a maybe-later). **Never leave it as a bare *Out of scope* text line**: that's the fragile case (a finished map's *Out of scope* is easy to lose, and a dependent wired to a text line has no real target). Leave only a one-line *Out of scope* **breadcrumb pointing at the artifact** (`→ future map: <name>, see <seed/idea link>`). It then **graduates into the current map** the moment this effort actually needs it (it "comes up on its own"), or **the next map charted on this repo harvests it**, its chart step asking which parked seeds/todos/ideas to pull in (see `../trailhead-chart/references/charting.md`, Mode 1 / Mode 1-bis). Whichever way, **the gate and its dependents move together** (a dependent seed's trigger points at the gate's artifact, a live target), never split across "ruled out" and re-routed.
|
|
7
|
+
|
|
8
|
+
**Re-route the gate, not just its dependents.** When the thing a deferred item waits on (its gate X) is itself an *Out of scope* / deferred line, X is deferred by the same test: **re-route X first** (its own `seed`/`idea`/`todo`, or a named future-milestone / another-map pointer), then **tie the dependents to X's new form**, never to a line that stays in *Out of scope*. A seed's trigger must point at a **live** target: a ticket/seed here, a named milestone/map, or a real external condition; a seed gated on a dead "ruled out" line has a trigger that can never fire. **Move a gate and its dependents together**, so the whole cluster leaves *Out of scope* at once (e.g. the *bolletta* becomes its own seed, and the "gated by the bolletta" items become seeds wired to it, none left behind as "ruled out").
|
|
9
|
+
|
|
10
|
+
## Stale lines noticed later
|
|
11
|
+
Only genuinely-beyond-the-destination work stays in *Out of scope*. The same test applies to **stale *Out of scope* lines** noticed later: `/trailhead:map` (read-only) **flags** any **un-parked** deferred line as an advisory (like blocked-by drift, it changes nothing) **and suggests running `/trailhead:inbox` to re-route them**, while `inbox` or an explicit user go-ahead does the **actual re-route**, a line that's really deferred becoming a seed/idea/todo instead of lingering as "ruled out". A line **already parked** (annotated `→ future map` with its dependent seeds wired to a live trigger) is settled: neither surface flags it again.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Principles
|
|
2
|
+
|
|
3
|
+
- **Execution inside the map (default).** Unlike a plan-only approach ("plan, don't do"), here the map carries **construction** within it: after the decision tickets, build tickets graduate from the fog and are **executed** as children of the map. The destination is the **working** artifact (a deployed app), not a spec document; the spec is a waypoint. *Override:* a project that must stop at the spec declares it in the map's `## Notes` (that wins over this default).
|
|
4
|
+
- **Refer by name.** Every map and ticket is an issue, so it has a **title**. In everything the human reads, refer by name, never by a bare `#number`. A wall of `#42, #43, #44` is illegible; names read at a glance. The id and URL don't vanish (a name wraps its link) but they ride *inside* the name. **One exception: a command the user must run.** In `/trailhead:work <ticket>` the argument is machine input, not prose; give the **bare number** there (a long title, with its punctuation and jargon, is awkward and error-prone to type). Name the ticket in the surrounding sentence, put the number in the command.
|
|
5
|
+
- **Result-oriented output.** In the chat, report **what was done and the next step**: name tickets, give the concrete next command. Never expose trailhead's internal machinery: Mode labels (`Mode 1`), protocol/step names (`DISCUSS`/`PLAN`/`VERIFY`, "the build engine", "graduate the fog", "the frontier"), wrapper/SKILL.md mechanics, or the conventions and config you are silently obeying: the git mode (`main`/`pr`), the isolation/worktree decision, submodule commit mechanics (commit-inside + gitlink bump), claim/assignment plumbing, and **how work fans out to subagents** (whether a step ran inline or as a subagent, the role name `executor`, the model `claude-sonnet-5`, the config key `models.execute`). To the user it is just **"an agent"**: say "I'll hand this to an agent with the exact spec, then run the code review over the whole diff", never "an executor on `claude-sonnet-5` per `models.execute`". Just act, then report the outcome. Surface a convention only when it **changes what the user must do** (a UAT they run, a release awaiting their command, a genuine claim collision with *another* login) or when they explicitly ask.
|
|
6
|
+
- **No em-dashes.** Write with ordinary punctuation: commas, colons, semicolons, parentheses, periods. Never use an em-dash (U+2014), and never a hyphen standing in for a comma. This holds for **everything trailhead produces**: ticket titles and bodies, engine comments, the map body, commit descriptions, chat output, and the skill's own source docs. En-dashes in numeric ranges (`1–2`) and the arrow `→` in trailhead's notation stay; only the em-dash is banned.
|
|
7
|
+
- **One ticket per session: hard rule.** Resolve **at most one** ticket per session; once one is resolved, do **not** start a second; end the session instead. The only exceptions: `research` tickets (AFK, run in parallel) and `capture` operations (they resolve nothing). On resolving a ticket, close the session with the **[Session handoff](session-handoff.md)** ritual.
|
|
8
|
+
- **Git per the repo's conventions.** The default is commit straight to `main`, conventional commits, no feature branch/PR, but the repo's `trailhead:conventions` issue governs: honour its `git:` (`main` | `pr`), `release:` (`command` | `auto`), and `isolation:` (`none` | `worktree`) header. Never `Co-Authored-By`. **Every commit made while resolving a ticket carries a `Refs: #<n>` trailer** (the ticket's issue number) as its last line, so GitHub threads the commit into that ticket's timeline (the commits show up *in* the ticket). Use a **non-closing** reference (`Refs:`): never a closing keyword (`fixes`/`closes`/`resolves #n`), because the ticket is closed **explicitly at Resolve** after Verify passes, not by a commit landing on the trunk. (Charting/config commits not tied to a ticket carry no `Refs:`.)
|