@marcomigozzi/trailhead 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +359 -0
- package/README.md +35 -22
- package/bin/lib/codex-projection.js +421 -0
- package/bin/lib/codex-projection.test.js +266 -0
- package/bin/lib/host-descriptor.js +404 -0
- package/bin/lib/host-descriptor.test.js +172 -0
- package/bin/trailhead.js +518 -67
- package/bin/trailhead.test.js +399 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +8 -3
- 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/lib/commit-message-check.js +74 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +83 -0
- package/plugins/trailhead/hooks/trailhead-check-update.js +54 -24
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +7 -10
- 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} +15 -3
- 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 +8 -5
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/acceptance-testing.md +3 -2
- package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/code-review.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +21 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +36 -0
- 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 +25 -450
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +28 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-capture}/references/capture.md +5 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +51 -0
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +24 -0
- package/plugins/trailhead/skills/{trailhead → trailhead-chart}/references/inbox.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +29 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +112 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +65 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +52 -0
- package/plugins/trailhead/templates/statusline-trailhead.sh +40 -8
- package/plugins/trailhead/templates/trailhead-commit-msg +57 -0
- 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/codebase-map.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/debug.md +0 -0
- /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/research.md +0 -0
- /package/plugins/trailhead/skills/{trailhead/references → _shared}/techniques/tdd.md +0 -0
- /package/plugins/trailhead/skills/{trailhead → trailhead-manage}/references/updating.md +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// commit-message-check.js: the single source of truth for trailhead's commit
|
|
3
|
+
// message rules, shared by two guardrails so they can never drift:
|
|
4
|
+
// - trailhead-commit-guard.js (Claude Code PreToolUse(Bash) hook)
|
|
5
|
+
// - templates/trailhead-commit-msg (host-independent git commit-msg hook,
|
|
6
|
+
// installed into .git/hooks at repo first-use so it runs on ANY host)
|
|
7
|
+
// Two rules: NEVER a Co-Authored-By trailer; a Conventional Commits subject
|
|
8
|
+
// (<type>(scope)?: subject) of at most 72 chars.
|
|
9
|
+
// Pure and dependency-free: takes a commit message string, returns a verdict.
|
|
10
|
+
|
|
11
|
+
const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
|
|
12
|
+
const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
|
|
13
|
+
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
14
|
+
const SCISSORS = '# ------------------------ >8';
|
|
15
|
+
|
|
16
|
+
// The message region: everything before git's --verbose scissors line (the
|
|
17
|
+
// block after it is the diff, not the message). Everything else is kept, so a
|
|
18
|
+
// Co-Authored-By hiding in a body/comment line is still caught.
|
|
19
|
+
function messageRegion(raw) {
|
|
20
|
+
const text = String(raw == null ? '' : raw);
|
|
21
|
+
const i = text.indexOf(SCISSORS);
|
|
22
|
+
return i === -1 ? text : text.slice(0, i);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Subject = first non-empty line of the message region. `opts.stripComments`
|
|
26
|
+
// (default true) also skips git comment (`#`) lines: correct for a commit-msg
|
|
27
|
+
// FILE, where git has not yet stripped its `#` boilerplate. It must be FALSE
|
|
28
|
+
// for a `git commit -m` string, where cleanup=whitespace keeps `#` lines as
|
|
29
|
+
// literal message text, so `#123 wip` is a real (non-conventional) subject and
|
|
30
|
+
// must NOT be treated as a comment. This is the seam the PreToolUse guard and
|
|
31
|
+
// the git hook diverge at (decision #16 defence in depth).
|
|
32
|
+
function subjectOf(raw, opts = {}) {
|
|
33
|
+
const stripComments = opts.stripComments !== false;
|
|
34
|
+
for (const line of messageRegion(raw).split('\n')) {
|
|
35
|
+
if (line.trim() === '') continue;
|
|
36
|
+
if (stripComments && line.startsWith('#')) continue;
|
|
37
|
+
return line.trim();
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// checkCommitMessage(message, opts?) -> { ok: true } | { ok: false, code, reason }
|
|
43
|
+
// opts.stripComments is forwarded to subjectOf (see there): the git commit-msg
|
|
44
|
+
// hook uses the default (file semantics); the PreToolUse `-m` guard passes false.
|
|
45
|
+
function checkCommitMessage(message, opts = {}) {
|
|
46
|
+
const region = messageRegion(message);
|
|
47
|
+
|
|
48
|
+
if (CO_AUTHORED.test(region)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
code: 'CO_AUTHORED_BY_FORBIDDEN',
|
|
52
|
+
reason: 'trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const subject = subjectOf(message, opts);
|
|
57
|
+
if (subject && !CONVENTIONAL.test(subject)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'CONVENTIONAL_COMMITS_VIOLATION',
|
|
61
|
+
reason: `trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (subject.length > 72) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
code: 'COMMIT_SUBJECT_TOO_LONG',
|
|
68
|
+
reason: 'trailhead: commit subject must be 72 characters or less.',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { checkCommitMessage, subjectOf, messageRegion, TYPES, CONVENTIONAL, CO_AUTHORED };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for commit-message-check.js. Run: node commit-message-check.test.js
|
|
3
|
+
// No framework: plain asserts, mirrors the style of trailhead-secret-guard.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Drives the SAME fixtures through two paths:
|
|
6
|
+
// 1. checkCommitMessage() directly (the source of truth).
|
|
7
|
+
// 2. the template hook (templates/trailhead-commit-msg) end-to-end via
|
|
8
|
+
// execFileSync, feeding it the message as a temp file.
|
|
9
|
+
// The two verdicts must always agree: that agreement is the anti-drift
|
|
10
|
+
// guarantee that keeps the Claude Code hook and the git commit-msg hook from
|
|
11
|
+
// ever diverging.
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const { execFileSync } = require('child_process');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const { checkCommitMessage } = require('./commit-message-check.js');
|
|
19
|
+
|
|
20
|
+
const templatePath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
24
|
+
|
|
25
|
+
const FIXTURES = [
|
|
26
|
+
{ name: 'valid subject', message: 'feat: add thing', expectOk: true },
|
|
27
|
+
{ name: 'valid with scope and body', message: 'fix(installer): x\n\nbody line', expectOk: true },
|
|
28
|
+
{ name: 'non-conventional subject', message: 'add thing', expectOk: false, code: 'CONVENTIONAL_COMMITS_VIOLATION' },
|
|
29
|
+
{ name: 'subject too long', message: 'feat: ' + 'x'.repeat(70), expectOk: false, code: 'COMMIT_SUBJECT_TOO_LONG' },
|
|
30
|
+
{ name: 'co-authored anywhere', message: 'feat: x\n\nCo-authored-by: A <a@b.c>', expectOk: false, code: 'CO_AUTHORED_BY_FORBIDDEN' },
|
|
31
|
+
{ name: 'leading git comment lines then valid subject', message: '# comment\n\nfeat: x', expectOk: true },
|
|
32
|
+
{
|
|
33
|
+
name: 'verbose scissors: co-authored past the scissors is ignored',
|
|
34
|
+
message: 'feat: x\n\n# ------------------------ >8 ------------------------\ndiff with Co-authored-by: junk',
|
|
35
|
+
expectOk: true,
|
|
36
|
+
},
|
|
37
|
+
{ name: 'empty message', message: '', expectOk: true },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// --- unit: checkCommitMessage() directly ---
|
|
41
|
+
for (const f of FIXTURES) {
|
|
42
|
+
const verdict = checkCommitMessage(f.message);
|
|
43
|
+
ok(`checkCommitMessage: ${f.name} -> ok=${f.expectOk}`, verdict.ok === f.expectOk);
|
|
44
|
+
if (!f.expectOk && f.code) {
|
|
45
|
+
ok(`checkCommitMessage: ${f.name} -> code=${f.code}`, verdict.code === f.code);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- end-to-end: drive the template hook on the SAME fixtures ---
|
|
50
|
+
function runTemplateHook(message) {
|
|
51
|
+
const tmpFile = path.join(os.tmpdir(), `trailhead-commit-msg-test-${process.pid}-${Math.random().toString(36).slice(2)}.txt`);
|
|
52
|
+
fs.writeFileSync(tmpFile, message);
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('node', [templatePath, tmpFile]);
|
|
55
|
+
return true; // exit 0 -> allow
|
|
56
|
+
} catch {
|
|
57
|
+
return false; // non-zero exit -> block
|
|
58
|
+
} finally {
|
|
59
|
+
fs.rmSync(tmpFile, { force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const f of FIXTURES) {
|
|
64
|
+
const allowed = runTemplateHook(f.message);
|
|
65
|
+
const expected = checkCommitMessage(f.message).ok;
|
|
66
|
+
ok(`template hook: ${f.name} -> allowed=${expected}`, allowed === expected);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- stripComments seam: the `-m` guard path (stripComments:false) must NOT
|
|
70
|
+
// treat a `#`-leading subject as a comment (git -m keeps `#` lines as text),
|
|
71
|
+
// while the default file-semantics path (strip=true) does. Regression guard for
|
|
72
|
+
// the parity gap the code review caught. ---
|
|
73
|
+
const hashSubject = '#123 wip do stuff';
|
|
74
|
+
ok('no-strip: #-leading subject is a real (non-conventional) subject -> block',
|
|
75
|
+
checkCommitMessage(hashSubject, { stripComments: false }).ok === false);
|
|
76
|
+
ok('no-strip: #-leading subject blocks with the conventional code',
|
|
77
|
+
checkCommitMessage(hashSubject, { stripComments: false }).code === 'CONVENTIONAL_COMMITS_VIOLATION');
|
|
78
|
+
ok('default strip: #-leading line is a comment -> empty subject -> allow',
|
|
79
|
+
checkCommitMessage(hashSubject).ok === true);
|
|
80
|
+
ok('template hook: #-leading message is file-semantics (comment) -> allow',
|
|
81
|
+
runTemplateHook(hashSubject) === true);
|
|
82
|
+
|
|
83
|
+
console.log(`✓ commit-message-check: ${passed} assertions passed`);
|
|
@@ -19,9 +19,14 @@ const THROTTLE_MS = 6 * 60 * 60 * 1000; // ricontrolla al massimo ogni 6h
|
|
|
19
19
|
function configDir() {
|
|
20
20
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
// La cache è per-host: Claude e Codex possono essere installati per lo stesso
|
|
23
|
+
// utente a versioni diverse, quindi non devono condividere throttle e verdetto
|
|
24
|
+
// (altrimenti entro le 6h un host emetterebbe la versione dell'altro). Claude
|
|
25
|
+
// mantiene il file storico (la statusline lo legge lì); Codex ha il suo.
|
|
26
|
+
function cacheFile(host) {
|
|
23
27
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
24
|
-
|
|
28
|
+
const name = host === 'codex' ? 'update-check-codex.json' : 'update-check.json';
|
|
29
|
+
return path.join(base, 'trailhead', name);
|
|
25
30
|
}
|
|
26
31
|
function readJSON(p) {
|
|
27
32
|
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
@@ -46,6 +51,15 @@ function cmp(a, b) {
|
|
|
46
51
|
|
|
47
52
|
// --- rileva canale + versione installata ---
|
|
48
53
|
function detect() {
|
|
54
|
+
// 0) codex: lo script vive sotto <codexHome>/skills/trailhead/hooks/, un
|
|
55
|
+
// percorso robusto e indipendente dall'env (non serve CODEX_HOME).
|
|
56
|
+
const onCodex = /[\\/]skills[\\/]trailhead[\\/]hooks$/.test(__dirname);
|
|
57
|
+
if (onCodex) {
|
|
58
|
+
const ver = (readFileSafe(path.join(__dirname, '..', 'VERSION')) || '').trim();
|
|
59
|
+
if (SEMVER.test(clean(ver))) {
|
|
60
|
+
return { channel: 'codex', version: clean(ver), where: path.join(__dirname, '..'), host: 'codex' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
49
63
|
const cfg = configDir();
|
|
50
64
|
// 1) plugin: elencato in installed_plugins.json
|
|
51
65
|
const ip = readJSON(path.join(cfg, 'plugins', 'installed_plugins.json'));
|
|
@@ -92,32 +106,48 @@ function latestGitHub() {
|
|
|
92
106
|
}
|
|
93
107
|
|
|
94
108
|
function main() {
|
|
95
|
-
const out = cacheFile();
|
|
96
|
-
// throttle: se la cache è fresca (<6h) non richiamare la rete
|
|
97
|
-
try {
|
|
98
|
-
const prev = readJSON(out);
|
|
99
|
-
if (prev && prev.checkedAt && Date.now() - prev.checkedAt < THROTTLE_MS) return;
|
|
100
|
-
} catch { /* ignore */ }
|
|
101
|
-
|
|
102
109
|
const info = detect();
|
|
103
110
|
if (!SEMVER.test(clean(info.version))) return; // versione installata sconosciuta: non azzardare
|
|
111
|
+
const out = cacheFile(info.host); // cache per-host: Codex non eredita il verdetto di Claude
|
|
104
112
|
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
// throttle: se la cache è fresca (<6h) riusa quel verdetto senza richiamare la rete
|
|
114
|
+
let prev = null;
|
|
115
|
+
try { prev = readJSON(out); } catch { prev = null; }
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
let verdict;
|
|
118
|
+
if (prev && prev.checkedAt && Date.now() - prev.checkedAt < THROTTLE_MS) {
|
|
119
|
+
verdict = prev;
|
|
120
|
+
} else {
|
|
121
|
+
const latest = (info.channel === 'npm' || info.channel === 'codex') ? latestNpm() : latestGitHub();
|
|
122
|
+
if (!SEMVER.test(clean(latest))) return; // rete/fonte non disponibile: lascia la cache com'è
|
|
123
|
+
|
|
124
|
+
verdict = {
|
|
125
|
+
channel: info.channel,
|
|
126
|
+
installed: clean(info.version),
|
|
127
|
+
latest: clean(latest),
|
|
128
|
+
updateAvailable: cmp(latest, info.version) > 0,
|
|
129
|
+
where: info.where || '',
|
|
130
|
+
checkedAt: Date.now(),
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
134
|
+
fs.writeFileSync(out + '.tmp', JSON.stringify(verdict));
|
|
135
|
+
fs.renameSync(out + '.tmp', out);
|
|
136
|
+
} catch { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Codex non ha una statusline: l'hook stesso deve segnalare l'update
|
|
140
|
+
// disponibile via additionalContext, su OGNI sessione (non solo quando la
|
|
141
|
+
// finestra di throttle si riapre). Su Claude (info.host è undefined) non
|
|
142
|
+
// scrive mai su stdout: comportamento invariato.
|
|
143
|
+
if (info.host === 'codex' && verdict && verdict.updateAvailable === true && SEMVER.test(clean(verdict.latest))) {
|
|
144
|
+
process.stdout.write(JSON.stringify({
|
|
145
|
+
hookSpecificOutput: {
|
|
146
|
+
hookEventName: 'SessionStart',
|
|
147
|
+
additionalContext: `Heads up: trailhead ${verdict.latest} is available (you have ${verdict.installed}). Run $trailhead update to install it.`,
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
// esegui in background: non blocca l'avvio della sessione.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
8
8
|
// Self-contained, no dependencies.
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const { checkCommitMessage } = require('./lib/commit-message-check.js');
|
|
11
|
+
|
|
12
12
|
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
13
13
|
|
|
14
14
|
function block(code, reason) {
|
|
@@ -56,14 +56,11 @@ process.stdin.on('end', () => {
|
|
|
56
56
|
|
|
57
57
|
const msg = extractMessage(cmd);
|
|
58
58
|
if (msg) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (subject.length > 72) {
|
|
65
|
-
block('COMMIT_SUBJECT_TOO_LONG', 'trailhead: commit subject must be 72 characters or less.');
|
|
66
|
-
}
|
|
59
|
+
// stripComments:false, this is the literal `-m` value, where git keeps
|
|
60
|
+
// `#` lines as message text (cleanup=whitespace), so a `#…` first line is
|
|
61
|
+
// a real subject to validate, not a comment to skip.
|
|
62
|
+
const verdict = checkCommitMessage(msg, { stripComments: false });
|
|
63
|
+
if (!verdict.ok) block(verdict.code, verdict.reason);
|
|
67
64
|
}
|
|
68
65
|
} catch {
|
|
69
66
|
// fall through to allow
|
|
@@ -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() === '');
|
|
@@ -5,14 +5,17 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
|
|
|
5
5
|
| Key | Values (default **bold**) | Effect |
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| `models.{plan,execute,research,review,debug}` | a model id (**inherit session**) | model for that activity, see note |
|
|
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 |
|
|
8
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 |
|
|
9
|
-
| `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 |
|
|
10
12
|
| `design.approval` | **`explicit`** \| `auto` | must a mockup be explicitly approved before the build proceeds to UI code? |
|
|
11
13
|
| `tdd` | **`seams`** \| `on` \| `off` | how the `build` engine tests |
|
|
12
14
|
| `acceptance.browser` | **`auto`** \| `on` \| `off` | how **Acceptance testing** runs |
|
|
13
15
|
| `testing.webapp` / `testing.url` | bool / url | is it browser-drivable, and where |
|
|
14
16
|
| `plan_review` | **`off`** \| `on` \| CLI list (`gemini,codex`) | send `build` PLANs to external AI CLIs for review (**Cross-AI plan review**) |
|
|
15
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 |
|
|
16
19
|
|
|
17
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:
|
|
18
21
|
- `plan` / `execute`: the `build`/`bug` engine's **Plan** and **Execute** steps (see below).
|
|
@@ -25,6 +28,10 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
|
|
|
25
28
|
- `execute` → an **executor subagent** that implements the plan with atomic commits on `models.execute`. Execute itself is autonomous (no live user exchange happens *during* implementation; new scope surfacing is captured/split, not grilled), so it fans out cleanly; the commits still land on `main`, visible. Run it **inline in the session instead when `models.execute` is unset or equals the session model** (nothing to gain from spawning).
|
|
26
29
|
- `research`/`review`/`debug`/codebase-map → their own subagents.
|
|
27
30
|
|
|
31
|
+
**Two independent host facts govern this, keyed on the descriptor (`bin/lib/host-descriptor.js`), not on a host name.** (1) A **subagent-less host** (`subagentToolkit == none`) has nothing to fan out into, so **every activity runs inline and sequentially** in the one session. (2) A host that **can't honour the model keys** (`honorsModelKeys == false`) can't run a subagent on a trailhead `models.*` value, so **every `models.*` key collapses to the single session model**. The two are independent: **Codex** is `subagentToolkit == full` (it fans out, via the native multi_agent toolkit) yet `honorsModelKeys == false` (its subagent model registry is OpenAI-only, so a Claude id like `claude-sonnet-5` can't run there and every subagent inherits the one session model). So on Codex the techniques *do* fan out; only the per-activity model *choice* is lost. The keys **stay in `.trailhead/config.json` untouched**: Claude Code honours them, a host that can't simply ignores them, and they apply again the next time the project runs on Claude Code. Nothing is stripped from the Claude `models.*` keys: they carry no `agents/*.toml` of their own and simply stay in config, ignored on Codex. The per-subagent OpenAI-model pinning once described here as a future upgrade has since shipped for `models.codex.*`: when those keys are set the installer emits one `~/.codex/agents/trailhead-<technique>.toml` per key and enables `features.multi_agent_v2`, and the runtime dispatches each activity by `agent_type` (adapter §D). When `models.*` is set while running on a host that can't honour it, the engine surfaces a **one-time notice** at session start (co-located with the inline check-update), so the user knows their per-activity split is not taking effect here; it fires once per project, tracked by a gitignored `.trailhead/models-collapse-notice` marker. On Codex the per-activity split is not lost outright, it moves to a parallel `models.codex.*` namespace holding OpenAI model ids (each key a bare model id, or a `{model, effort}` pair projected as `model_reasoning_effort`); `models.*` (Claude ids) still collapses to the session model as described above. The one-time collapse notice now points the user at `models.codex.*` as the fix, and stays silent once any `models.codex.*` key is set, since that means the user has already opted into the honoured namespace there.
|
|
32
|
+
|
|
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.
|
|
34
|
+
|
|
28
35
|
If a value is unset, inherit the session model (never hard-fail on an unknown id: warn and inherit).
|
|
29
36
|
|
|
30
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.
|
|
@@ -33,12 +40,16 @@ If a value is unset, inherit the session model (never hard-fail on an unknown id
|
|
|
33
40
|
|
|
34
41
|
**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.
|
|
35
42
|
|
|
36
|
-
**acceptance.browser.** `auto` (default) = drive the browser when `testing.webapp` + `testing.url` are set,
|
|
43
|
+
**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.
|
|
44
|
+
|
|
45
|
+
**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`.
|
|
37
46
|
|
|
38
47
|
**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.
|
|
39
48
|
|
|
40
49
|
**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.
|
|
41
50
|
|
|
51
|
+
**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`.
|
|
52
|
+
|
|
42
53
|
Example `config.json` (same shape for the project `.trailhead/config.json` and the global `~/.claude/trailhead/config.json`):
|
|
43
54
|
```json
|
|
44
55
|
{ "models": { "plan": "claude-opus-4-8", "execute": "claude-sonnet-5" }, "tdd": "seams", "acceptance": { "browser": "auto" } }
|
|
@@ -54,7 +65,8 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
|
|
|
54
65
|
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.
|
|
55
66
|
3. **🧠 Models**: **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 default). 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.
|
|
56
67
|
- **Anti-pattern, never do this:** 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. Plan is asked, answered, and only then is Execute asked, as a second menu. Two questions, two answers, always.
|
|
57
|
-
|
|
68
|
+
- **On Codex (host-detected), ask `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`.
|
|
69
|
+
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).
|
|
58
70
|
5. **🧪 TDD**: 🎯 Seams only · ✅ On (all definable behaviour) · ⛔ Off.
|
|
59
71
|
6. **🖥️ Acceptance testing**: 🤖 Auto · ▶️ Always browser · 🙋 Guided UAT only. If browser is enabled, ask **🔗 base URL** and confirm it's a web app.
|
|
60
72
|
7. **🧑⚖️ Plan review**: **before presenting this menu, first probe the PATH for known external AI CLIs** (e.g. `gemini`, `codex`, and any others the Cross-AI plan review technique recognises): run the detection, then build the menu from what was actually found. Never present this step with a hard-coded two-option list; the options are computed from the probe:
|
|
@@ -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:`.)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
## Session handoff
|
|
2
|
+
|
|
3
|
+
Resolving a ticket **ends the session**: one ticket per session is a hard rule (see [Principles](principles.md)). So every resolution closes the same way. After the resolution comment + `gh issue close` + the `Decisions so far` update, **remove the session-ticket marker** (`.trailhead/session-ticket` at the working root, if you wrote one, see Mode 2), then **always** sign off with, in order:
|
|
4
|
+
|
|
5
|
+
1. **A one-line confirmation** the ticket is resolved, by name (not a bare `#number`).
|
|
6
|
+
2. **The next step: a scannable block whose FIRST line is `/clear`, always.** Never bury it in prose, and **never point at the next ticket without leading with `/clear`**. `/clear` is its own first bullet, phrased as the first thing to do ("fai `/clear`, poi..."): **never demote it to a trailing parenthetical** on the next command. `/trailhead:work 29 (dopo /clear)` is the bug, not the fix: the reader runs the command and only then reads the note, when the context is already dirty. State the `/clear` **every time** (not only when context is heavy): it must come *before* the next command, on its own line first, so the next ticket starts on clean context. Render it as a short labelled list in the user's conversation language, along these lines:
|
|
7
|
+
|
|
8
|
+
> **Prossimo passo:**
|
|
9
|
+
> - `/clear`: per liberare il contesto
|
|
10
|
+
> - poi `/trailhead:work <numero>` per lavorare **<nome del prossimo ticket>** (oppure `/trailhead:work` che prende **<numero>** in automatico)
|
|
11
|
+
> - in alternativa `/trailhead:map` per la vista completa e scegliere tu, o `/trailhead:dashboard` per l'indice di tutte le mappe e del whiteboard
|
|
12
|
+
> - oppure `/trailhead:quick` (o `/trailhead:quick "<il pezzo>"` se sai già cosa) per lavorare un pezzo fuori mappa, aperto e portato a termine subito
|
|
13
|
+
|
|
14
|
+
Name the next frontier ticket so the user knows what they're taking; put its **number** in the command (never the title). **When the frontier holds several mutually independent tickets, don't crown one: present them as a set to choose from**, naming each with its number (see **Frontier order carries no priority**). Bare `/trailhead:work` still acts on one, but the block keeps the choice visible. The block is the list of things to do **after the `/clear`** (it leads with it) and stays on screen in the terminal to read from, so each command must be either self-sufficient (a number already known) or safe to type by hand. The commands a handoff block may contain are `/clear`, `/trailhead:work` (bare or `<number>`), `/trailhead:map`, `/trailhead:dashboard`, and `/trailhead:quick`; **in a whiteboard session** (the resolved ticket was `trailhead:whiteboard`), also `/trailhead:whiteboard`. **Always offer the read-only views alongside the next action**: `/trailhead:map` for the low-res map and `/trailhead:dashboard` for the repo-wide index (all maps + the whiteboard), so the user can look before choosing. **`/trailhead:quick` is the one command that may carry a free-text argument here**: `/trailhead:quick "<short seed>"` when a concrete off-map piece is actually at hand (its argument is forgiving freeform seed text, not an exact title to match, so pre-filling it is helpful, not error-prone), or **bare `/trailhead:quick`** as the generic "if you want to work a piece off the map" offer, which prompts for the piece itself; keep any seed short and quote-safe. **`/trailhead:work` always takes the number, never the title** of an existing ticket, and **never hand the user a `/trailhead:ticket <type> "<title>"`** (or any other title-bearing command): adding a map ticket needs the diverge-first framing (Mode 3), so it is never pre-filled.
|
|
15
|
+
|
|
16
|
+
**If the frontier is empty**, that usually means fog that this resolution made specifiable should have been **graduated into a ticket already** (Mode 2 step 5), so the frontier isn't actually empty. If it genuinely is (nothing ticketable yet, or the user must choose which fog item to take next), point the block to **`/trailhead:map`** and offer to open the next ticket *yourself* once they say which one, rather than pre-filling a `/trailhead:ticket` command for them. **Fog still on the map takes priority over loose whiteboard work.** If the map still has fog (unspecified areas in *Not yet specified*), lead with **`/trailhead:map`** to dissolve it: that fog is the map's own remaining work, so graduating it comes before any off-map ticket. **Only when the map is genuinely complete** (frontier empty *and* no fog left) and the whiteboard still holds open loose tickets, do not sign off as if nothing remains: check for them and, if any exist, **surface them**, pointing the block at **`/trailhead:whiteboard`** (or **`/trailhead:dashboard`** for the full index) and naming the loose tickets so the user can pick one up with `/trailhead:quick <n>`. **In a whiteboard session** the fog-graduation reasoning does not apply (the whiteboard has no fog): point the block at **`/trailhead:whiteboard`** (and `/trailhead:quick <n>` for the next loose ticket), not the map. (The example is Italian; match whatever language you're conversing in.)
|
|
17
|
+
|
|
18
|
+
**Pending codebase offer (greenfield): surface it here, exactly once.** If this session's Work-the-map step 1 marked a greenfield codebase-issue offer **pending** (the repo has no `trailhead:codebase` issue but now holds substantial code), surface it **as a distinct, prominent line inside this block**, never as trailing prose after it. It is **an offer the user accepts with a yes, not a command they type** (there is no `/trailhead:codebase` command), so give it its own bullet phrased as a yes/no offer, along the lines of *"vuoi che generi l'issue codebase adesso? (una mappa del repo, fissata tra i pin)"* (in the user's conversation language). Show it **exactly once**: on a yes, generate and pin it via the **Codebase map** technique; on a no, don't nag again this session. This is the one non-command item the block may carry, and a whiteboard handoff never carries it (even if `work <n>` ran step 1 against a background map, the offer belongs to that map, not this loose ticket).
|
|
19
|
+
|
|
20
|
+
**This block is required whenever you point the user at the next ticket**, including in a **later follow-up** (after answering their questions, not only in the instant you resolve the ticket) **and in a capture's confirmation line** when it tells the user how to work the just-captured ticket. Never write "next: `/trailhead:work …`" without the `/clear` before it. **A capture is not a resolution**, so its confirmation needn't render the full labelled block: a compact, future-conditional one-liner is right, along the lines of *"to work it later: `/clear`, then `/trailhead:work 45`"* (in the user's conversation language), but the `/clear`-before-`/trailhead:work` order is non-negotiable there too. A bare `/trailhead:work 45` with no `/clear`, or a `/trailhead:work 45 (dopo /clear)` that hides `/clear` in a trailing parenthetical instead of leading with it, is the bug: `/clear` is always the first thing, on its own, before the next command.
|
|
21
|
+
|
|
22
|
+
This ritual fires from every **Resolve** step (`build`, `bug`) and from **Mode 2**'s close (which resolves `decision` / `prototype` / `task` too). It does **not** fire after a `capture` (resolves nothing) or a parallel `research` batch (AFK, not session-ending).
|