@marcomigozzi/trailhead 0.11.1 → 0.12.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 +6 -3
- package/README.md +6 -3
- package/bin/lib/codex-projection.js +2 -1
- package/bin/lib/codex-projection.test.js +4 -1
- package/bin/trailhead.js +23 -13
- package/bin/trailhead.test.js +307 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/adopt.md +5 -1
- package/plugins/trailhead/commands/audit.md +10 -0
- package/plugins/trailhead/commands/auto.md +5 -1
- package/plugins/trailhead/commands/bug.md +5 -1
- package/plugins/trailhead/commands/config.md +5 -1
- package/plugins/trailhead/commands/dashboard.md +5 -1
- package/plugins/trailhead/commands/grill.md +5 -1
- package/plugins/trailhead/commands/idea.md +5 -1
- package/plugins/trailhead/commands/inbox.md +5 -1
- package/plugins/trailhead/commands/map.md +5 -1
- package/plugins/trailhead/commands/new.md +5 -1
- package/plugins/trailhead/commands/note.md +5 -1
- package/plugins/trailhead/commands/pause.md +5 -1
- package/plugins/trailhead/commands/prune.md +5 -1
- package/plugins/trailhead/commands/quick.md +6 -2
- package/plugins/trailhead/commands/resume.md +5 -1
- package/plugins/trailhead/commands/seed.md +5 -1
- package/plugins/trailhead/commands/split.md +5 -1
- package/plugins/trailhead/commands/ticket.md +5 -1
- package/plugins/trailhead/commands/todo.md +5 -1
- package/plugins/trailhead/commands/update.md +5 -1
- package/plugins/trailhead/commands/whiteboard.md +5 -1
- package/plugins/trailhead/commands/work.md +5 -1
- package/plugins/trailhead/hooks/hooks.json +9 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +56 -4
- package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
- package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
|
@@ -49,6 +49,17 @@
|
|
|
49
49
|
// that flag's value is exempted, and only for docker/podman verbs; see
|
|
50
50
|
// ENV_FILE_EXEMPT_VERBS below.
|
|
51
51
|
//
|
|
52
|
+
// Name-only operand positions: `git check-ignore` / `git ls-files` /
|
|
53
|
+
// `git rm --cached` pathspecs and the destination of a one-source `cp`/`mv`
|
|
54
|
+
// are names, never contents (the checks that keep a secret OUT of git, and
|
|
55
|
+
// `cp .env.example .env`). Only those positions are exempt, behind closed
|
|
56
|
+
// option sets that fail closed; see GIT_PATHSPEC_SUBCOMMANDS and
|
|
57
|
+
// COPY_NAME_ONLY_FLAGS. Templates (`.env.example`, `.env.*.sample`,
|
|
58
|
+
// `.template`, `.dist`) are not secrets: see NON_SECRET_ENV_SUFFIXES.
|
|
59
|
+
// Known gap (write class, not read): the cp/mv destination exemption can move
|
|
60
|
+
// a link prepared under a non-secret name onto `.env`
|
|
61
|
+
// (`ln -s .env.example l && mv l .env`).
|
|
62
|
+
//
|
|
52
63
|
// Deliberately best-effort on the Bash leg, like the sibling guards: not a
|
|
53
64
|
// full shell parser. It does not care about `cd` shape at all (that is
|
|
54
65
|
// trailhead-search-guard.js's job) — it only asks "does any token here
|
|
@@ -73,22 +84,33 @@ const {
|
|
|
73
84
|
|
|
74
85
|
// --- path helpers ------------------------------------------------------------
|
|
75
86
|
|
|
87
|
+
// Suffissi finali di `.env.<...>` che indicano un template committato e
|
|
88
|
+
// privo di segreti (`.env.example`, `.env.local.sample`, ...), non un segreto.
|
|
89
|
+
// Conta solo l'ULTIMO segmento: `.env.example.bak` resta un segreto.
|
|
90
|
+
const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
|
|
91
|
+
|
|
76
92
|
// A basename is a "secret pattern" when it is exactly `.env`, exactly
|
|
77
93
|
// `.secrets`, or `.env.<something>` (e.g. `.env.local`, `.env.production`),
|
|
78
94
|
// matched case-insensitively (`.ENV`/`.Secrets` are the same file on a
|
|
79
95
|
// case-insensitive filesystem). Near-misses like `env.sample` (no leading
|
|
80
|
-
// dot) or `.environment` (no dot after `.env`) must NOT match
|
|
96
|
+
// dot) or `.environment` (no dot after `.env`) must NOT match, and neither
|
|
97
|
+
// must a template whose last segment is in NON_SECRET_ENV_SUFFIXES.
|
|
81
98
|
function isSecretBasename(name) {
|
|
82
99
|
if (!name) return false;
|
|
83
100
|
const lower = String(name).toLowerCase();
|
|
84
101
|
if (lower === '.env' || lower === '.secrets') return true;
|
|
85
|
-
|
|
102
|
+
if (!/^\.env\..+$/.test(lower)) return false;
|
|
103
|
+
return !NON_SECRET_ENV_SUFFIXES.has(lower.slice(lower.lastIndexOf('.') + 1));
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
// Anche la forma `<rev>:<path>` (`git show HEAD:.env`, `scp host:.env .`):
|
|
107
|
+
// il path dopo il primo `:` è letto quanto un operando nudo.
|
|
88
108
|
function isSecretPathArg(t) {
|
|
89
109
|
const bare = stripQuotes(t);
|
|
90
110
|
if (!bare) return false;
|
|
91
|
-
|
|
111
|
+
if (isSecretBasename(basename(bare))) return true;
|
|
112
|
+
const colon = bare.indexOf(':');
|
|
113
|
+
return colon !== -1 && isSecretBasename(basename(bare.slice(colon + 1)));
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
// --- Bash leg: quote-aware, statement-based scan -----------------------------
|
|
@@ -110,6 +132,56 @@ const VALUE_FLAGS = new Set(['-f', '--file']);
|
|
|
110
132
|
const ENV_FILE_EXEMPT_VERBS = new Set(['docker', 'podman', 'docker-compose', 'podman-compose']);
|
|
111
133
|
const ENV_FILE_EXEMPT_FLAGS = new Set(['--env-file']);
|
|
112
134
|
const NO_EXEMPT_FLAGS = new Set();
|
|
135
|
+
const NO_SKIP = new Set();
|
|
136
|
+
|
|
137
|
+
// Sottocomandi git che riportano o rimuovono lo stato ignore/tracking di un
|
|
138
|
+
// path senza mai stamparne il contenuto: i loro pathspec sono NOMI. Ognuno ha
|
|
139
|
+
// il set CHIUSO delle sue opzioni senza valore (`git <sub> -h`, git 2.49):
|
|
140
|
+
// qualsiasi altra opzione ritira l'esenzione dall'intero statement (fail
|
|
141
|
+
// closed), così il valore di un'opzione non è mai scambiato per un pathspec
|
|
142
|
+
// (`-X <file>` legge il file; `--pathspec-from-file=<file>` ne stampa le
|
|
143
|
+
// righe nell'errore "did not match"). `requires` = opzione senza la quale il
|
|
144
|
+
// sottocomando resta controllato (`git rm` solo con `--cached`).
|
|
145
|
+
const GIT_PATHSPEC_SUBCOMMANDS = new Map([
|
|
146
|
+
['check-ignore', {
|
|
147
|
+
flags: new Set(['-q', '--quiet', '-v', '--verbose', '--stdin', '-z', '-n', '--non-matching', '--no-index', '--index']),
|
|
148
|
+
}],
|
|
149
|
+
['ls-files', {
|
|
150
|
+
flags: new Set([
|
|
151
|
+
'-z', '-t', '-v', '-f', '-c', '--cached', '-d', '--deleted', '-m', '--modified', '-o', '--others',
|
|
152
|
+
'-i', '--ignored', '-s', '--stage', '-k', '--killed', '-u', '--unmerged', '--directory', '--eol',
|
|
153
|
+
'--no-empty-directory', '--resolve-undo', '--exclude-standard', '--full-name',
|
|
154
|
+
'--recurse-submodules', '--error-unmatch', '--abbrev', '--debug', '--deduplicate', '--sparse',
|
|
155
|
+
]),
|
|
156
|
+
}],
|
|
157
|
+
['rm', {
|
|
158
|
+
flags: new Set(['--cached', '-f', '--force', '-n', '--dry-run', '-q', '--quiet', '-r', '--ignore-unmatch', '--sparse']),
|
|
159
|
+
requires: '--cached',
|
|
160
|
+
}],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
// Opzioni globali di git (usage line, git 2.49), per trovare il sottocomando.
|
|
164
|
+
// Un'opzione con valore consuma la parola successiva salvo forma
|
|
165
|
+
// `--opt=value`; un'opzione in nessuno dei due set fa fail closed, così un
|
|
166
|
+
// valore non è mai letto come sottocomando (`git -C ls-files show HEAD:.env`
|
|
167
|
+
// esegue `show`).
|
|
168
|
+
const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
|
|
169
|
+
const GIT_GLOBAL_FLAGS = new Set([
|
|
170
|
+
'-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
|
|
171
|
+
'--no-optional-locks', '--no-advice', '--bare',
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
// `cp`/`mv` scrivono la destinazione e non la stampano: un nome segreto che è
|
|
175
|
+
// SOLO la destinazione di una copia/spostamento a sorgente singola è un nome
|
|
176
|
+
// (`cp .env.example .env`). Vale per esattamente due operandi preceduti da
|
|
177
|
+
// opzioni di questo set chiuso; tutto il resto lascia lo statement
|
|
178
|
+
// controllato: `-t` (ogni operando diventa sorgente), backup (`-b`,
|
|
179
|
+
// `--backup`, `-S`: il vecchio segreto sopravvive come `.env~`), link (`-l`,
|
|
180
|
+
// `-s`) o `--exchange` (la destinazione condivide o scambia il segreto). Le
|
|
181
|
+
// opzioni devono precedere gli operandi: una in coda è opzione con la
|
|
182
|
+
// permutazione GNU ma operando con POSIXLY_CORRECT, quindi quale parola sia
|
|
183
|
+
// la destinazione dipenderebbe da un ambiente che l'hook non vede.
|
|
184
|
+
const COPY_NAME_ONLY_FLAGS = new Set(['-f', '--force', '-i', '--interactive', '-n', '--no-clobber', '-v', '--verbose']);
|
|
113
185
|
|
|
114
186
|
// The pattern-first search verbs (grep/rg/ag/sed/awk), shared with
|
|
115
187
|
// trailhead-search-guard.js. Their first positional is a PATTERN, not a file
|
|
@@ -139,9 +211,11 @@ function stripTrailingPunct(t) {
|
|
|
139
211
|
// redirect target. Returns the offending token or null. `exemptFlags` (#180)
|
|
140
212
|
// names flags whose value must be skipped entirely rather than scanned, in
|
|
141
213
|
// both the `--flag=value` and spaced `--flag value` forms.
|
|
142
|
-
function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
214
|
+
function scanTokensForSecretPath(tokens, exemptFlags, skipIndices) {
|
|
143
215
|
const exempt = exemptFlags || NO_EXEMPT_FLAGS;
|
|
216
|
+
const skip = skipIndices || NO_SKIP;
|
|
144
217
|
for (let i = 0; i < tokens.length; i++) {
|
|
218
|
+
if (skip.has(i)) continue; // posizione nome-soltanto: mai letta
|
|
145
219
|
const t = stripTrailingPunct(tokens[i]);
|
|
146
220
|
if (!t) continue;
|
|
147
221
|
|
|
@@ -175,7 +249,7 @@ function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
|
175
249
|
// `-f value` / `--file value` (separate-token value).
|
|
176
250
|
if (VALUE_FLAGS.has(t)) {
|
|
177
251
|
const next = i + 1 < tokens.length ? stripTrailingPunct(tokens[i + 1]) : null;
|
|
178
|
-
if (next && isSecretPathArg(next)) return next;
|
|
252
|
+
if (next && !skip.has(i + 1) && isSecretPathArg(next)) return next;
|
|
179
253
|
continue;
|
|
180
254
|
}
|
|
181
255
|
// Glued short-option value (`-f.env`, finding #2): a single-dash flag
|
|
@@ -197,6 +271,77 @@ function scanTokensForSecretPath(tokens, exemptFlags) {
|
|
|
197
271
|
return null;
|
|
198
272
|
}
|
|
199
273
|
|
|
274
|
+
// Vero se `text` è un'opzione lunga elencata, o un cluster corto (`-ci`) di
|
|
275
|
+
// cui OGNI lettera è elencata. `--opt=value`, un'abbreviazione o un cluster
|
|
276
|
+
// che contiene un'opzione con valore (`-ciX`) non lo sono.
|
|
277
|
+
function isListedFlag(text, flags) {
|
|
278
|
+
if (text.startsWith('--')) return flags.has(text);
|
|
279
|
+
return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
|
|
283
|
+
// un'opzione fuori da GIT_GLOBAL_VALUE_OPTIONS / GIT_GLOBAL_FLAGS.
|
|
284
|
+
function gitSubcommandIndex(operands) {
|
|
285
|
+
for (let k = 0; k < operands.length; k++) {
|
|
286
|
+
const t = operands[k];
|
|
287
|
+
if (!t.startsWith('-')) return k;
|
|
288
|
+
if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) { k++; continue; }
|
|
289
|
+
if (GIT_GLOBAL_FLAGS.has(t)) continue;
|
|
290
|
+
const eq = t.indexOf('=');
|
|
291
|
+
const isLongValueForm = t.startsWith('--') && eq !== -1 && GIT_GLOBAL_VALUE_OPTIONS.has(t.slice(0, eq));
|
|
292
|
+
if (!isLongValueForm) return -1;
|
|
293
|
+
}
|
|
294
|
+
return -1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Indici dei pathspec di un sottocomando in GIT_PATHSPEC_SUBCOMMANDS. git
|
|
298
|
+
// permuta le opzioni (una può seguire un pathspec); dopo `--` ogni parola è
|
|
299
|
+
// un pathspec. Sottocomando confrontato case-sensitive, come fa git.
|
|
300
|
+
function gitPathspecIndices(operands) {
|
|
301
|
+
const sub = gitSubcommandIndex(operands);
|
|
302
|
+
const spec = sub === -1 ? undefined : GIT_PATHSPEC_SUBCOMMANDS.get(operands[sub]);
|
|
303
|
+
if (!spec) return NO_SKIP;
|
|
304
|
+
const pathspecs = new Set();
|
|
305
|
+
let required = spec.requires === undefined;
|
|
306
|
+
let endOfOptions = false;
|
|
307
|
+
for (let k = sub + 1; k < operands.length; k++) {
|
|
308
|
+
const t = operands[k];
|
|
309
|
+
if (endOfOptions || !t.startsWith('-') || t === '-') pathspecs.add(k);
|
|
310
|
+
else if (t === '--') endOfOptions = true;
|
|
311
|
+
else if (!isListedFlag(t, spec.flags)) return NO_SKIP;
|
|
312
|
+
else if (t === spec.requires) required = true;
|
|
313
|
+
}
|
|
314
|
+
return required ? pathspecs : NO_SKIP;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Indice della destinazione di un `cp`/`mv` a sorgente singola (vedi
|
|
318
|
+
// COPY_NAME_ONLY_FLAGS).
|
|
319
|
+
function copyDestinationIndices(operands) {
|
|
320
|
+
const positional = [];
|
|
321
|
+
let endOfOptions = false;
|
|
322
|
+
for (let k = 0; k < operands.length; k++) {
|
|
323
|
+
const t = operands[k];
|
|
324
|
+
if (endOfOptions || !t.startsWith('-') || t === '-') positional.push(k);
|
|
325
|
+
else if (positional.length) return NO_SKIP; // opzione o `--` dopo un operando
|
|
326
|
+
else if (t === '--') endOfOptions = true;
|
|
327
|
+
else if (!isListedFlag(t, COPY_NAME_ONLY_FLAGS)) return NO_SKIP;
|
|
328
|
+
}
|
|
329
|
+
return positional.length === 2 ? new Set([positional[1]]) : NO_SKIP;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Indici dei token (verbo incluso, indice 0) che lo statement consuma come
|
|
333
|
+
// NOME e mai come CONTENUTO: pathspec di git check-ignore/ls-files/rm
|
|
334
|
+
// --cached e destinazione di cp/mv. Solo queste posizioni saltano il
|
|
335
|
+
// controllo; ogni altro token resta controllato, quindi l'esenzione non può
|
|
336
|
+
// riciclare una lettura altrove nello statement.
|
|
337
|
+
function nameOnlyTokenIndices(bin, tokens) {
|
|
338
|
+
const operands = tokens.slice(1).map((t) => stripQuotes(stripTrailingPunct(t)));
|
|
339
|
+
let indices = NO_SKIP;
|
|
340
|
+
if (bin === 'git') indices = gitPathspecIndices(operands);
|
|
341
|
+
else if (bin === 'cp' || bin === 'mv') indices = copyDestinationIndices(operands);
|
|
342
|
+
return new Set([...indices].map((k) => k + 1));
|
|
343
|
+
}
|
|
344
|
+
|
|
200
345
|
// Does `stmtTokens` invoke `bash -c "<cmd>"` / `sh -c "<cmd>"` / `eval
|
|
201
346
|
// "<cmd>"` (etc.)? If so, return the inner command TEXT (quotes stripped, not
|
|
202
347
|
// yet re-tokenized) so the caller can recurse into it as a nested command.
|
|
@@ -243,7 +388,7 @@ function scanStatementForSecretPath(stmt) {
|
|
|
243
388
|
// standalone docker-compose/podman-compose binaries) get --env-file's value
|
|
244
389
|
// exempted; every other verb keeps today's behaviour unchanged.
|
|
245
390
|
const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
|
|
246
|
-
return scanTokensForSecretPath(tokens, exempt);
|
|
391
|
+
return scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
|
|
247
392
|
}
|
|
248
393
|
|
|
249
394
|
// Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
|
|
@@ -129,6 +129,62 @@ ok('hook allows Bash(podman run --env-file=.env img) end-to-end (exit 0, no outp
|
|
|
129
129
|
const d3 = runHook('Bash', { command: 'cat .env' });
|
|
130
130
|
ok('hook still denies Bash(cat .env) end-to-end (exit 2, block decision)', d3.code === 2 && /"decision":"block"/.test(d3.out));
|
|
131
131
|
|
|
132
|
+
// --- template env files (.env.example/.sample/.template/.dist) are not secrets ---
|
|
133
|
+
const allowCmd = (c) => ok(`Bash \`${c}\` is allowed`, !detectSecretRead('Bash', { command: c }));
|
|
134
|
+
const denyCmd = (c) => ok(`Bash \`${c}\` is denied`, !!detectSecretRead('Bash', { command: c }));
|
|
135
|
+
ok('Read .env.example is allowed', !detectSecretRead('Read', { file_path: '.env.example' }));
|
|
136
|
+
ok('Read .env.local.example is allowed', !detectSecretRead('Read', { file_path: 'app/.env.local.example' }));
|
|
137
|
+
ok('Read .ENV.SAMPLE is allowed', !detectSecretRead('Read', { file_path: '.ENV.SAMPLE' }));
|
|
138
|
+
allowCmd('cat .env.template');
|
|
139
|
+
allowCmd('cat .env.dist');
|
|
140
|
+
ok('Read .env.example.bak is still denied', !!detectSecretRead('Read', { file_path: '.env.example.bak' }));
|
|
141
|
+
ok('Read .env.examples is still denied', !!detectSecretRead('Read', { file_path: '.env.examples' }));
|
|
142
|
+
|
|
143
|
+
// --- name-only operand positions: git pathspecs, cp/mv destination ---
|
|
144
|
+
// Questi comandi non stampano mai il contenuto del file: sono i controlli che
|
|
145
|
+
// tengono un segreto fuori da git, e il guard non deve bloccarli.
|
|
146
|
+
allowCmd('git check-ignore -v .env');
|
|
147
|
+
allowCmd('git check-ignore -q .env .secrets');
|
|
148
|
+
allowCmd('git ls-files --error-unmatch .env');
|
|
149
|
+
allowCmd('git ls-files -ci --exclude-standard -- .env');
|
|
150
|
+
allowCmd('git rm --cached .env');
|
|
151
|
+
allowCmd('git rm -r --cached -f .env');
|
|
152
|
+
allowCmd('git -C app check-ignore .env');
|
|
153
|
+
allowCmd('git --no-pager ls-files .env');
|
|
154
|
+
allowCmd('cp .env.example .env');
|
|
155
|
+
allowCmd('cp -f .env.example .env');
|
|
156
|
+
allowCmd('mv .env.sample .env');
|
|
157
|
+
allowCmd('cp -- .env.example .env');
|
|
158
|
+
allowCmd('git check-ignore .env && cp .env.example .env');
|
|
159
|
+
// Fail-closed: forme che leggono, o fuori dal set di opzioni chiuso.
|
|
160
|
+
denyCmd('git show HEAD:.env');
|
|
161
|
+
denyCmd('git rm .env');
|
|
162
|
+
denyCmd('git rm -- --cached .env');
|
|
163
|
+
denyCmd('git rm --cached --pathspec-from-file=.env');
|
|
164
|
+
denyCmd('git ls-files -X .env');
|
|
165
|
+
denyCmd('git ls-files -ciX .env');
|
|
166
|
+
denyCmd('git -C ls-files show HEAD:.env');
|
|
167
|
+
denyCmd('git --unknown check-ignore .env');
|
|
168
|
+
denyCmd('git diff .env');
|
|
169
|
+
denyCmd('cp .env /tmp/x');
|
|
170
|
+
denyCmd('cp .env .env.bak');
|
|
171
|
+
denyCmd('mv .env /tmp/x');
|
|
172
|
+
denyCmd('cp -t /tmp .env.example .env');
|
|
173
|
+
denyCmd('cp .env.example .env /tmp');
|
|
174
|
+
denyCmd('cp -b .env.example .env');
|
|
175
|
+
denyCmd('cp --backup .env.example .env');
|
|
176
|
+
denyCmd('cp -l .env.example .env');
|
|
177
|
+
denyCmd('cp -s .env.example .env');
|
|
178
|
+
denyCmd('mv --exchange .env.example .env');
|
|
179
|
+
denyCmd('cp .env.example .env -f');
|
|
180
|
+
denyCmd('cp .env.example .env && cat .env');
|
|
181
|
+
denyCmd('git check-ignore .env; cat .env');
|
|
182
|
+
|
|
183
|
+
const a6 = runHook('Bash', { command: 'git check-ignore -v .env' });
|
|
184
|
+
ok('hook allows Bash(git check-ignore -v .env) end-to-end (exit 0, no output)', a6.code === 0 && a6.out.trim() === '');
|
|
185
|
+
const a7 = runHook('Bash', { command: 'cp .env.example .env' });
|
|
186
|
+
ok('hook allows Bash(cp .env.example .env) end-to-end (exit 0, no output)', a7.code === 0 && a7.out.trim() === '');
|
|
187
|
+
|
|
132
188
|
// --- crash safety ---
|
|
133
189
|
const crash1 = runHook('Read', undefined);
|
|
134
190
|
ok('missing tool_input never crashes (exit 0)', crash1.code === 0);
|
|
@@ -54,6 +54,8 @@ This is the operational recipe every applicable call-site follows. It is **manda
|
|
|
54
54
|
|
|
55
55
|
Worked examples: 2 real options + delegate + defer = 4 -> all in the menu; 3 real options + delegate + defer = 5 -> the menu carries the 3 real options and delegate/defer are offered in the prose line right after it; 2 real options + delegate (defer not applicable) = 3 -> in the menu.
|
|
56
56
|
|
|
57
|
+
**Menu call-sites (an `AskUserQuestion` trailhead authors): links live in the prose.** A picker renders its question and options as plain text, so nothing in it is clickable. When a question is about something the user has to open first (a mockup to approve or pick from, a published page, a PR, a ticket comment), write its clickable link(s) in the reply text **immediately before** the menu, in that same message, never only earlier in the conversation, never as a name in place of the URL, and never inside the picker. If there is no link to show yet, do not ask. For mockups the full rule is "Surface the mockup" in `techniques/prototype.md`, enforced by the mockup-link hooks (Claude Code and Codex).
|
|
58
|
+
|
|
57
59
|
**Prose / conversational call-sites (grilling, or any choice put in words rather than a menu).** There is no cap: **state the applicable affordance(s) in words** in the same message that puts the choice (for example append "Oppure: scegli tu, propongo io / scelgo dopo se dipende da un prerequisito."). Grilling stays human-decides, so delegate is not offered there; **defer is offered in prose when the decision is genuinely blocked on a prerequisite.**
|
|
58
60
|
|
|
59
61
|
`(Recommended)` on a real option is a separate, static hint that may coexist with the affordances; it is not the delegate option.
|
|
@@ -39,7 +39,7 @@ Effective config = built-in defaults ← global `~/.claude/trailhead/config.json
|
|
|
39
39
|
|
|
40
40
|
**Pinned model review.** After an update that changed the tier defaults, the engine offers to review pinned `models.*` / `models.codex.*` ids that are older than their tier's new default (listed `key: old -> new`), with three choices: update (write the new ids, codex effort unchanged), keep (nothing to do, not asked again for these defaults), or open `/trailhead:config` to pick per key. The review evaluates the **effective** pins: the global `~/.claude/trailhead/config.json` merged under the project's `.trailhead/config.json`, project winning key by key (a project's own key, even `null`, `''`, or `inherit session`, always wins over the global value; `models.codex` follows the same per-key rule, so a stale pin inherited from the global config is flagged too, even for a project that pins nothing itself). Each stale line names the config file it actually came from, so the update choice edits the right one. The check (`model-defaults-review.js`'s `check(cwd)`, CLI subcommand `check`) is **read-only**: it never writes an ack by itself, so it keeps reoffering until the user actually answers. The ack (CLI subcommand `ack`, writing the acknowledged defaults version) is written only after the user picks one of the three choices, never just for the notice having been shown; it lives in the gitignored `.trailhead/model-defaults-ack` file when a project is found above `cwd`, whether the stale pin itself is project or global. With no project found above `cwd`, the review runs on the global config alone (if one exists) and its ack lives next to it, at `<config dir>/trailhead/model-defaults-ack` (config dir = `$CLAUDE_CONFIG_DIR` or `~/.claude`). The check runs both in the `trailhead-check-update.js` SessionStart hook and at skill config load (`../_shared/load-first.md`), once per session either way. Unset keys already follow the new defaults, so they are never flagged; unknown ids (custom or unrecognised) are never flagged either. The tier history lives in `hooks/lib/model-defaults.json`. Hookless hosts run the same `node <hooks>/lib/model-defaults-review.js check <dir>` / `... ack <dir>` pair inline at session start.
|
|
41
41
|
|
|
42
|
-
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-5-5`, standard → `claude-sonnet-5-5`, fast →
|
|
42
|
+
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-5-5`, standard → `claude-sonnet-5-5`, fast → `claude-haiku-5-5`). 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.
|
|
43
43
|
|
|
44
44
|
**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-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. (On Claude Code the `Agent` tool takes only a family alias, so at subagent spawn the stored id is mapped to its alias, see the dispatch rule in `techniques.md`; the full id is still what gets stored, so the exact version stays on record and drives the inline-vs-subagent decision, even though the alias-only spawn cannot convey that version to the subagent.) **`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.
|
|
45
45
|
|
|
@@ -52,7 +52,7 @@ If a value is unset, it resolves to its **Balanced**-profile default for that ac
|
|
|
52
52
|
| **Low** | standard | standard | fast | fast | standard | fast | fast |
|
|
53
53
|
|
|
54
54
|
**Tier-class -> id**, resolved from the lineup actually reachable at setup time (verify, don't trust these verbatim):
|
|
55
|
-
- **Claude** (`models.*`, ids only, no effort): strong = Opus-class, resolved to **`claude-opus-5-5`** by default (`claude-opus-5` stays offered in the per-key menus) · standard = Sonnet-class, resolved to **`claude-sonnet-5-5`** by default (so Balanced's `execute` runs on Sonnet 5.5; `claude-sonnet-5` stays offered in the per-key menus) · fast = Haiku-class.
|
|
55
|
+
- **Claude** (`models.*`, ids only, no effort): strong = Opus-class, resolved to **`claude-opus-5-5`** by default (`claude-opus-5` stays offered in the per-key menus) · standard = Sonnet-class, resolved to **`claude-sonnet-5-5`** by default (so Balanced's `execute` runs on Sonnet 5.5; `claude-sonnet-5` stays offered in the per-key menus) · fast = Haiku-class, resolved to **`claude-haiku-5-5`** by default (`claude-haiku-4-5` stays offered in the per-key menus).
|
|
56
56
|
- **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`.
|
|
57
57
|
- Changing a tier default must append a complete new entry to `hooks/lib/model-defaults.json` in the same commit.
|
|
58
58
|
|
|
@@ -101,7 +101,7 @@ Example `config.json` (same shape for the project `.trailhead/config.json` and t
|
|
|
101
101
|
3. **🌐 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.
|
|
102
102
|
4. **🧠 Models**: a **profile** (Balanced by default) applied whole, or **Manual** for the per-key questions.
|
|
103
103
|
- **🎚️ 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 seven 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`.
|
|
104
|
-
- **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-5` / `claude-opus-5`, `claude-sonnet-5-5` / `claude-sonnet-5`, `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`/`verify`/`codebase-map` sit behind an "Advanced" option, each its own separate choice, same versioned rule.
|
|
104
|
+
- **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-5` / `claude-opus-5`, `claude-sonnet-5-5` / `claude-sonnet-5`, `claude-haiku-5-5` / `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`/`verify`/`codebase-map` sit behind an "Advanced" option, each its own separate choice, same versioned rule.
|
|
105
105
|
- **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.)
|
|
106
106
|
- **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`/`verify`/`codebase-map` 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`.
|
|
107
107
|
5. **🎨 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).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
## Principles
|
|
2
2
|
|
|
3
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.
|
|
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. **But keep the number visible next to the name**, never hidden inside a link: the user works tickets by number, so a name with no number in sight forces them to look it up. Render a ticket as its name followed by its number, e.g. *Add login rate limit* (#45) (the URL may wrap the name), above all when you report a ticket you just opened, captured, or split. **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
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
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
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.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Resolving a ticket **ends the session**: one ticket per session is a hard rule (see [Principles](principles.md)). So every resolution closes the same way. Render every command in this ritual in this host's command forms (the **Host command forms** rule in `load-first.md`). After the resolution comment + `gh issue close` + the `Decisions so far` update, **remove the session-ticket marker** (`.trailhead/session-ticket` at the working root, if you wrote one, see Mode 2), then **always** sign off with, in order:
|
|
4
4
|
|
|
5
5
|
1. **A short recap of what was done** (2 to 4 lines) for the resolved ticket: the change in plain language, the files or areas touched (when applicable), and the outcome (verified / released / next step). Keep it **result-oriented**: say what changed and what it means, never trailhead's internal machinery (no mode or step names, no engine-role or technique names). It **augments** the ritual (it does not replace anything below): the recap sits above the one-line confirmation and gives the user a quick account of the ticket without reconstructing it from the commits or the issue timeline. It rides with a **resolution** only; a decision-ticket resolution may touch no files, hence "when applicable", and a capture or research batch (which never reach this ritual) carry no recap. It also carries forward any **pending follow-up** the Pending follow-up guard below identified, so a follow-up surfaced while working never has to be reconstructed from scrollback.
|
|
6
|
-
2. **A one-line confirmation** the ticket is resolved, by name (
|
|
6
|
+
2. **A one-line confirmation** the ticket is resolved, by name and number, both visible (e.g. *Add login rate limit* (#45), never the name alone nor a bare `#number`).
|
|
7
7
|
3. **The next step: a scannable block whose FIRST line is `/clear`, always.** Never bury it in prose, and **never point at the next ticket without leading with `/clear`**. `/clear` is its own first bullet, phrased as the first thing to do ("fai `/clear`, poi..."): **never demote it to a trailing parenthetical** on the next command. `/trailhead:work 29 (dopo /clear)` is the bug, not the fix: the reader runs the command and only then reads the note, when the context is already dirty. State the `/clear` **every time** (not only when context is heavy): it must come *before* the next command, on its own line first, so the next ticket starts on clean context. Render it as a short labelled list in the user's conversation language, along these lines:
|
|
8
8
|
|
|
9
9
|
> **Prossimo passo:**
|
|
@@ -173,6 +173,13 @@ These renders create and pin only when missing; they never rewrite the body.
|
|
|
173
173
|
- **per open map, also flag GitHub's 100 sub-issue cap**: read `sub_issues_summary.total` (below) and flag `near cap · <total>/100` (total >= 90) or `at cap` (total >= 100). For only the (rare) at/near-cap maps, run one bounded native sub-issue list (see `## Sub-issue cap and pruning` above) to check for closed edges, exactly like the bounded fog-confirm above, and append `· prunable` when any are found. The render offers `/trailhead:prune` for such a map, never auto-prunes;
|
|
174
174
|
- the whiteboard as a section, or a link to the `/trailhead:whiteboard` view;
|
|
175
175
|
- dynamic counts: untriaged inbox size and whiteboard frontier size.
|
|
176
|
+
- **the last-audit marker, carried through untouched.** If the existing body holds a hidden line `<!-- trailhead:last-audit <ISO-8601 UTC> -->` (written by `/trailhead:audit`, see `../trailhead-manage/references/auditing.md`), copy that line **verbatim** into every regenerated body, never dropped, reformatted, or re-dated. Only `/trailhead:audit` changes its value, and only on a confirmed marker advance, through the same fail-safe read-modify-write:
|
|
177
|
+
```bash
|
|
178
|
+
# read the marker (empty output = no audit yet); the body was already read non-empty per the fail-safe rule
|
|
179
|
+
gh issue view <dashboard> --json body --jq .body | grep -o '<!-- trailhead:last-audit [^ ]* -->'
|
|
180
|
+
# write: replace the marker line if present, else append it, in the body-file you then pass to `gh issue edit <dashboard> --body-file`
|
|
181
|
+
sed -i -E 's|<!-- trailhead:last-audit [^ ]* -->|<!-- trailhead:last-audit <ISO-8601 UTC> -->|' <body-file> # or append the line when absent
|
|
182
|
+
```
|
|
176
183
|
|
|
177
184
|
Count queries, `--json ... --jq 'length'` style:
|
|
178
185
|
```bash
|
|
@@ -194,4 +201,4 @@ gh api repos/{owner}/{repo}/issues/<map> --jq '.sub_issues_summary.total'
|
|
|
194
201
|
## First-use repo setup (do ALL THREE, every chart or adopt, never skip any)
|
|
195
202
|
1. Create any missing labels with `gh label create` (all eighteen: `trailhead:map`, `trailhead:codebase`, `trailhead:conventions`, `trailhead:dashboard`, `trailhead:ticket`, the six type labels, `trailhead:blocked`, `trailhead:seed`, `trailhead:out-of-scope`, `trailhead:superseded`, `trailhead:unverified`, `trailhead:fog`, `trailhead:whiteboard`).
|
|
196
203
|
2. **Check the label guard is installed**: `gh api repos/{owner}/{repo}/contents/.github/workflows/trailhead-label-guard.yml`; if it's absent (404), install it: **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. This is part of standing up trailhead in a repo, not an optional extra: *check every time*, so a repo can never end up with the labels but no guard.
|
|
197
|
-
3. **Sync the commit-msg git hook**: run the commit-msg hook sync script (`trailhead-commit-msg-sync.js`; installs it if absent, upgrades an outdated trailhead hook in place, never touches a foreign one) so trailhead's commit discipline (Conventional Commits subject, no `Co-Authored-By`, and a `Refs: #<n>` trailer while a `.trailhead/session-ticket` marker is active) runs on **every** `git commit` regardless of host (the Claude-Code `PreToolUse` guard stays too, defence in depth): **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. If the script is missing or node errors, fall back to inspecting the hooks dir (`git rev-parse --git-path hooks`) per `teamwork.md`, never a blind "not enforced". Like the label guard, *check every time*; unlike it, `.git/hooks/` is per-clone local state (untracked), so it is installed, never committed.
|
|
204
|
+
3. **Sync the commit-msg git hook**: run the commit-msg hook sync script (`trailhead-commit-msg-sync.js`; installs it if absent, upgrades an outdated trailhead hook in place, never touches a foreign one) so trailhead's commit discipline (Conventional Commits subject, no `Co-Authored-By`, and a `Refs: #<n>` trailer while a `.trailhead/session-ticket` marker is active) runs on **every** `git commit` regardless of host (the Claude-Code `PreToolUse` guard stays too, defence in depth): **read `teamwork.md` (Trust & provenance → Repo-side enforcement) for the exact steps**. If the script is missing or node errors (missing means none of the three template dirs listed in `teamwork.md` has it: try them all first), fall back to inspecting the hooks dir (`git rev-parse --git-path hooks`) per `teamwork.md`, never a blind "not enforced". Like the label guard, *check every time*; unlike it, `.git/hooks/` is per-clone local state (untracked), so it is installed, never committed.
|
|
@@ -94,4 +94,4 @@ The map issue's **body** is a shared mutable document: two sessions appending to
|
|
|
94
94
|
|
|
95
95
|
**Repo-side enforcement (part of first-use setup, not an afterthought).** trailhead **installs the label guard for you**; don't tell the user to copy files. **Every chart or adopt, *check* whether `.github/workflows/trailhead-label-guard.yml` exists** (the first-use repo setup, in SKILL.md's Substrate section); if it's absent, install it, by default on a repo with other collaborators, and as a quick offer on a clearly solo/private one (cheap insurance), but **never silently skip the check**: that's exactly how a repo ends up with trailhead labels and no guard. On go-ahead, write the shipped label-guard template (`${CLAUDE_PLUGIN_ROOT}/templates/trailhead-label-guard.yml` for a plugin install, or `~/.claude/trailhead/templates/trailhead-label-guard.yml` for an npm install, take whichever exists) to the repo's `.github/workflows/trailhead-label-guard.yml` and commit it (conventional message; the commit-guard hook keeps it clean). The Action strips any `trailhead:*` label added by a non-write actor and comments why; set an optional `TRAILHEAD_LABEL_ALLOWLIST` repo variable to whitelist extra logins (e.g. a bot trailhead runs as). **Caveat:** pushing a workflow file needs a token with the `workflow` scope; if the push is rejected, run `gh auth refresh -s workflow` and retry (or, as a last resort, tell the user to add the file by hand).
|
|
96
96
|
|
|
97
|
-
**The commit-msg git hook (host-independent commit guard).** On every host trailhead ALSO installs a git **`commit-msg`** hook as a host-independent backstop (defence in depth alongside the host's own `PreToolUse` guard on Claude Code and Codex alike), enforcing the two rules (a Conventional Commits subject of at most 72 chars; never a `Co-Authored-By` trailer) plus, while a `.trailhead/session-ticket` marker is present at the worktree root, a `Refs: #<n>` trailer matching its number, on every `git commit`, whatever host drives it (decision #16). **Every chart or adopt, and at every `work`/`quick` ticket start, run the sync script**: `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>`, where `<templates>` is
|
|
97
|
+
**The commit-msg git hook (host-independent commit guard).** On every host trailhead ALSO installs a git **`commit-msg`** hook as a host-independent backstop (defence in depth alongside the host's own `PreToolUse` guard on Claude Code and Codex alike), enforcing the two rules (a Conventional Commits subject of at most 72 chars; never a `Co-Authored-By` trailer) plus, while a `.trailhead/session-ticket` marker is present at the worktree root, a `Refs: #<n>` trailer matching its number, on every `git commit`, whatever host drives it (decision #16). **Every chart or adopt, and at every `work`/`quick` ticket start, run the sync script**: `node <templates>/trailhead-commit-msg-sync.js --repo <working-root>`, where `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains `trailhead-commit-msg-sync.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty (it is often absent from the Bash environment), and never stop at the first miss. It prints one JSON status line and handles every case itself: absent -> installs it; an older trailhead hook (recognised by its own header line, not by trusting the filename) -> upgraded in place; already current -> left alone; a foreign (non-trailhead) hook -> never touched, only reported (offer to chain it); under a repo's `core.hooksPath`, an existing trailhead hook there is still upgraded in place, but nothing is newly added to that directory. Unlike the label guard, `.git/hooks/` is not version-controlled, so this is a per-clone local install with nothing to commit. **If the sync script is missing (none of the three dirs has it: an outdated install) or node errors, never claim git enforces nothing before looking**: resolve the effective hooks dir with `git -C <working-root> rev-parse --git-path hooks` (resolve a relative output against the working root; it honours `core.hooksPath` and the git common dir shared by linked worktrees), read `<hooks dir>/commit-msg`, and classify it by its header line (a trailhead hook has `// trailhead commit-msg hook (git).` among its first 5 lines, trimmed; never trust the filename alone; a symlinked `commit-msg` counts as foreign, as the script treats it). Report one result-oriented line: (a) trailhead hook present and executable (`test -x`): git does enforce trailhead's rules here, but its freshness could not be checked or upgraded, so suggest updating the trailhead install; a trailhead hook that is NOT executable: git skips it, so it does not enforce trailhead's rules here (suggest updating the install so the sync script can fix the mode); (b) a foreign hook (or a symlink): left untouched, git does not enforce trailhead's rules here; (c) no hook: git does not enforce trailhead's rules here (suggest updating the install so the sync script can install it). Never blocks the work.
|
|
@@ -31,7 +31,7 @@ If genuinely ambiguous and the user's unreachable, default by context (backend m
|
|
|
31
31
|
|
|
32
32
|
Either way the rough screen is **reacted to before real UI code**.
|
|
33
33
|
|
|
34
|
-
**"Surface the mockup" means: put its link in the same message that asks for the reaction.** The user can't react to what they can't open. Every message that asks to approve, react to, or pick between mockups **must carry, in that same message and right before the question**, the direct clickable location of each screen it refers to: the file path (`disk`), the Artifact / claude.ai/design project URL (`canvas`), the design-system project URL plus the screen's page (`design-system`), or the Stitch project/screen URL (`stitch`), one line per screen or variant. A link given earlier in the conversation, only in a ticket comment, or implied ("the mockup above") does **not** count: scrollback scrolls away
|
|
34
|
+
**"Surface the mockup" means: put its link in the same message that asks for the reaction.** The user can't react to what they can't open. Every message that asks to approve, react to, or pick between mockups **must carry, in that same message and right before the question**, the direct clickable location of each screen it refers to: the file path (`disk`), the Artifact / claude.ai/design project URL (`canvas`), the design-system project URL plus the screen's page (`design-system`), or the Stitch project/screen URL (`stitch`), one line per screen or variant. A link given earlier in the conversation, only in a ticket comment, or implied ("the mockup above") does **not** count: scrollback scrolls away. **Write the link(s) in the plain prose of the message, never inside a structured prompt**: an options picker renders its question and options as plain text, so a link put there is not clickable (above all on mobile). When the approval then goes through a structured prompt, keep every link in the prose just before it and let the picker ask only a plain yes/no: approve the mockup (yes) or request changes (no); for a pick between variants, name each variant in the options and keep their links in the prose. If you don't have a link to show yet (publish failed, URL not returned), **do not ask for approval**: fix the publish or report the failure first. The project's or design system's **name is not a link** ("the mockup is ready on MyApp" without the URL fails this rule), and the link must sit in the reply **text**, not only in your reasoning. A hook enforces this: on Claude Code the **mockup-link guard** blocks a mockup-approval `AskUserQuestion` whose preceding reply text carries no URL or file path; on Codex **mockup-link-stop** continues a final reply that asks for the approval without one. When either fires, write the link(s) in the prose and ask again.
|
|
35
35
|
|
|
36
36
|
How approval gates the build depends on `config.design.approval`:
|
|
37
37
|
- **`explicit` (default)**: surface the mockup and **wait for the user's clear go-ahead**; do not write UI code until they approve. Record the approval on the ticket, then proceed.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: trailhead
|
|
3
3
|
description: "Start and drive a large project (more than one agent session can hold) as a map of decision tickets on GitHub Issues, resolving one at a time until the way to the destination is clear. Self-contained (zero external-plugin dependencies: trailhead ships its own `trailhead-*` engine agents and needs only an authenticated gh CLI): map-based onboarding and a discuss→plan→execute→verify engine with atomic commits, with everything (map, discussion, plan, verification) living on the Issues. Invoke only when the user explicitly runs `/trailhead` (or a `/trailhead:<verb>` command) or asks to chart, adopt, or work a trailhead map, and do not auto-invoke for generic planning, project, or task-tracking requests."
|
|
4
|
-
argument-hint: "[new|adopt|work|quick|whiteboard|ticket|inbox|map|dashboard|config|grill|split|pause|resume|auto|update|prune|bug|todo|idea|seed|note] [text | ticket]"
|
|
4
|
+
argument-hint: "[new|adopt|work|quick|whiteboard|ticket|inbox|map|dashboard|config|grill|split|pause|resume|auto|update|prune|audit|bug|todo|idea|seed|note] [text | ticket]"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
`trailhead` charts a large project as a **shared map on GitHub Issues**, then works its **tickets** one at a time until the way to the destination is clear. This skill is the **entry point**: bare `/trailhead` runs **smart entry** (detect the repo state and propose the next move); a verb runs that verb's engine. Everything lives on the Issues; the repo holds code only.
|
|
@@ -15,7 +15,7 @@ Before doing anything, read `../_shared/load-first.md` and follow it: the shared
|
|
|
15
15
|
The **first word** of the arguments is the verb; the rest is the text or a ticket number. `/trailhead <verb>` and `/trailhead:<verb>` are equivalent.
|
|
16
16
|
|
|
17
17
|
- **No verb** to **smart entry** (below): this skill handles it directly.
|
|
18
|
-
- **A verb** to hand off to the skill that owns it, passing the whole argument string. **The skill-split migration ([map #53](https://github.com/ToRvaLDz/trailhead/issues/53)) lands the cluster skills one at a time.** The **chart cluster is built (#59)**: `new`, `adopt`, `ticket`, `inbox`, and `grill` route to the **`trailhead-chart`** skill (call the Skill tool with skill name `trailhead-chart` and arguments `<verb> <rest>`). The **work cluster is built (#60)**: `work`, `quick`, `pause`, `resume`, `split`, and `auto` route to the **`trailhead-work`** skill (call the Skill tool with skill name `trailhead-work` and arguments `<verb> <rest>`). The **view cluster is built (#61)**: `map`, `dashboard`, and `whiteboard` route to the **`trailhead-view`** skill (call the Skill tool with skill name `trailhead-view` and arguments `<verb> <rest>`). The **capture cluster is built (#62)**: `bug`, `todo`, `idea`, `seed`, and `note` route to the **`trailhead-capture`** skill (call the Skill tool with skill name `trailhead-capture` and arguments `<verb> <rest>`). The **manage cluster is built (#63)**: `config`, `update`, and `
|
|
18
|
+
- **A verb** to hand off to the skill that owns it, passing the whole argument string. **The skill-split migration ([map #53](https://github.com/ToRvaLDz/trailhead/issues/53)) lands the cluster skills one at a time.** The **chart cluster is built (#59)**: `new`, `adopt`, `ticket`, `inbox`, and `grill` route to the **`trailhead-chart`** skill (call the Skill tool with skill name `trailhead-chart` and arguments `<verb> <rest>`). The **work cluster is built (#60)**: `work`, `quick`, `pause`, `resume`, `split`, and `auto` route to the **`trailhead-work`** skill (call the Skill tool with skill name `trailhead-work` and arguments `<verb> <rest>`). The **view cluster is built (#61)**: `map`, `dashboard`, and `whiteboard` route to the **`trailhead-view`** skill (call the Skill tool with skill name `trailhead-view` and arguments `<verb> <rest>`). The **capture cluster is built (#62)**: `bug`, `todo`, `idea`, `seed`, and `note` route to the **`trailhead-capture`** skill (call the Skill tool with skill name `trailhead-capture` and arguments `<verb> <rest>`). The **manage cluster is built (#63)**: `config`, `update`, `prune`, and `audit` route to the **`trailhead-manage`** skill (call the Skill tool with skill name `trailhead-manage` and arguments `<verb> <rest>`). Then carry out the target skill's instructions.
|
|
19
19
|
- **Unrecognised verb** to treat the whole string as `idea` (route to `trailhead-capture` as `idea <text>`).
|
|
20
20
|
|
|
21
21
|
The namespaced `/trailhead:<verb>` commands reach their target skill directly through their own command wrapper; this routing table only covers the bare `/trailhead <verb>` form.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Capture without breaking flow, but with the **tracker** as destination, not scattered files. The verbs are named in this cluster's `../SKILL.md`; here is the detail of each. One capture = one action + one confirmation line; it resolves nothing.
|
|
4
4
|
|
|
5
|
-
**Confirmation line.** Confirm the capture by the ticket's name (
|
|
5
|
+
**Confirmation line.** Confirm the capture by the ticket's name and its number, both visible (e.g. *Add login rate limit* (#45), never the name alone nor a bare `#number`), and where it landed. **If you point at working it later, lead with `/clear`**: the confirmation is a place you point at the next ticket, so the [Session handoff](../../_shared/session-handoff.md)'s `/clear`-first rule applies: a compact, future-conditional one-liner is fine (a capture is not a resolution, so no full labelled block), along the lines of *"to work it later: `/clear`, then `/trailhead:work 45`; or, for the lighter path, `/trailhead:quick 45`"* (in the user's conversation language). Never a bare `/trailhead:work 45` with no `/clear`, and (the same post-`/clear` invariant the [Session handoff](../../_shared/session-handoff.md) states) **never place after `/clear` an action the agent must perform that depends on context `/clear` erases**: a capture confirmation points only at user-runnable commands, which satisfies this by construction, so keep it that way. **Whenever the confirmation does point at working the ticket (a `seed` parked on its trigger, or a fog `note`/`idea` that is not a ticket yet, is the case where it does not), anchor the next step to `/trailhead:work <n>`, always:** it is the recommendation (the full engine, which can split and keeps the map record), and the confirmation names it first. `/trailhead:quick <n>` is the **lighter alternative** (a single alternative, not two): it works that same ticket whole right now, always size-triaged so on a small change it offers to skip the heavy steps (plan pass, TDD, full code review, goal-backward verify) on an explicit yes, grills only if needed, skips the map narrative book-keeping, and never splits; a map ticket stays on its map, so never call it "off the map". **Describe it by that purpose (the lighter, faster path), never as merely "without splitting"**: not splitting is a side effect, not what `quick` is for. It is offered *alongside* `work`, **never in its place** and **never rendered twice on one line**. This holds for a **whiteboard** capture too, where both apply (`quick` is the no-split variant of `work`): the anchor stays `/trailhead:work <n>` for consistency. Same spirit as chart's codified close (#147). Render the command in this host's command forms (the **Host command forms** rule in `../../_shared/load-first.md`).
|
|
6
6
|
|
|
7
7
|
**Language (do not skip):** the captured ticket (title + body) is written in `config.ticket.language` (default `en`), **regardless of the language you are chatting in**. A capture fired in Italian still lands as an English ticket. This is the easiest place to slip, because a capture echoes the user's one-line phrasing; translate it, do not mirror it. See the *Ticket language* rule in `../../_shared/ticket-language.md`.
|
|
8
8
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: trailhead-manage
|
|
3
3
|
user-invocable: false
|
|
4
|
-
description: "trailhead manage cluster: the admin verbs that keep a trailhead install healthy. Show the effective config or change it through a guided menu (config); check for a newer trailhead and install it where that is safe (update),
|
|
5
|
-
argument-hint: "[config|update|prune] [get|set … | map]"
|
|
4
|
+
description: "trailhead manage cluster: the admin verbs that keep a trailhead install healthy. Show the effective config or change it through a guided menu (config); check for a newer trailhead and install it where that is safe (update), prune a map's sub-issue edges to reclaim slots under GitHub's 100-cap (prune), and audit closed tickets for regressions against the code and the open ones for duplicates (audit). A cohesion cluster of the trailhead skill split, loading the shared `_shared/` core; reached through the `/trailhead:<verb>` command wrappers and the bare `/trailhead` dispatcher. Not auto-invoked: it runs only when one of these verbs is dispatched."
|
|
5
|
+
argument-hint: "[config|update|prune|audit] [get|set … | map | --all]"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`trailhead-manage` is the **manage cluster** of the trailhead skill: the admin verbs that keep a trailhead install healthy. `config` shows the effective config or changes it (a guided menu, or `get` / `set <key> <value>`); `update` checks for a newer trailhead and installs it where that is safe; `prune` reclaims a map's native sub-issue slots under GitHub's 100-cap. Everything lives on the GitHub Issues; the repo holds code only.
|
|
8
|
+
`trailhead-manage` is the **manage cluster** of the trailhead skill: the admin verbs that keep a trailhead install healthy. `config` shows the effective config or changes it (a guided menu, or `get` / `set <key> <value>`); `update` checks for a newer trailhead and installs it where that is safe; `prune` reclaims a map's native sub-issue slots under GitHub's 100-cap; `audit` checks closed tickets for regressions against today's code and finds duplicate or groupable open ones. Everything lives on the GitHub Issues; the repo holds code only.
|
|
9
9
|
|
|
10
10
|
## Load first, in order
|
|
11
11
|
|
|
@@ -15,10 +15,11 @@ These admin verbs resolve no ticket and set up no isolation workspace.
|
|
|
15
15
|
|
|
16
16
|
## Routing: verb to engine
|
|
17
17
|
|
|
18
|
-
The **first word** of the arguments is the verb (`config`, `update`, or `
|
|
18
|
+
The **first word** of the arguments is the verb (`config`, `update`, `prune`, or `audit`); the rest is that verb's argument (`get`, `set <key> <value>`, a map, `--all`, or empty).
|
|
19
19
|
|
|
20
20
|
- **`config [get|set …]`** to **`../_shared/configuration-reference.md`**: the config keys, their semantics, and the guided menu setup. With no argument it runs the guided setup; `config get` prints the merged config read-only (project `.trailhead/config.json` over global over defaults, showing which source wins each key); `config set <key> <value>` writes one key directly (to the project `.trailhead/config.json`, or to `~/.claude/trailhead/config.json` with `--global`). Read that file and follow it.
|
|
21
21
|
- **`update`** to **`references/updating.md`**: the update protocol (detect the install channel, compare the installed version with the latest from the matching source, install the newer one where it is safe). Read that file and follow it.
|
|
22
22
|
- **`prune [map]`** to **`references/pruning.md`**: reclaim native sub-issue slots on a map near or at GitHub's 100 sub-issue cap by dropping the edges of closed and orphan tickets and re-adding missing ones, keeping every reference (Parent line, Decisions-so-far pointer, map label). With no argument it prunes the active map. Read that file and follow it.
|
|
23
|
+
- **`audit [map] [--all]`** to **`references/auditing.md`**: audit the closed tickets since the last audit (or all of them with `--all`) for regressions against the current code (a fast no-agent path for unchanged code), classing each as implemented, unchanged, evolved, implemented wrong, or not implemented, and compare the open tickets for duplicates and groupable clusters; every fix is a proposal applied only on an explicit yes. With no argument it covers every open map plus the whiteboard; with a map it covers that map only. Read that file and follow it.
|
|
23
24
|
|
|
24
25
|
The full config **key reference** is a single source, shared with the rest of the skill: `../_shared/configuration-reference.md` (keys + guided setup).
|