@marcomigozzi/trailhead 0.11.0 → 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.
Files changed (63) hide show
  1. package/README.it.md +6 -3
  2. package/README.md +6 -3
  3. package/bin/lib/codex-projection.js +2 -1
  4. package/bin/lib/codex-projection.test.js +4 -1
  5. package/bin/trailhead.js +27 -15
  6. package/bin/trailhead.test.js +417 -2
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/commands/adopt.md +5 -1
  10. package/plugins/trailhead/commands/audit.md +10 -0
  11. package/plugins/trailhead/commands/auto.md +5 -1
  12. package/plugins/trailhead/commands/bug.md +5 -1
  13. package/plugins/trailhead/commands/config.md +5 -1
  14. package/plugins/trailhead/commands/dashboard.md +5 -1
  15. package/plugins/trailhead/commands/grill.md +5 -1
  16. package/plugins/trailhead/commands/idea.md +5 -1
  17. package/plugins/trailhead/commands/inbox.md +5 -1
  18. package/plugins/trailhead/commands/map.md +5 -1
  19. package/plugins/trailhead/commands/new.md +5 -1
  20. package/plugins/trailhead/commands/note.md +5 -1
  21. package/plugins/trailhead/commands/pause.md +5 -1
  22. package/plugins/trailhead/commands/prune.md +5 -1
  23. package/plugins/trailhead/commands/quick.md +6 -2
  24. package/plugins/trailhead/commands/resume.md +5 -1
  25. package/plugins/trailhead/commands/seed.md +5 -1
  26. package/plugins/trailhead/commands/split.md +5 -1
  27. package/plugins/trailhead/commands/ticket.md +5 -1
  28. package/plugins/trailhead/commands/todo.md +5 -1
  29. package/plugins/trailhead/commands/update.md +5 -1
  30. package/plugins/trailhead/commands/whiteboard.md +5 -1
  31. package/plugins/trailhead/commands/work.md +5 -1
  32. package/plugins/trailhead/hooks/hooks.json +9 -0
  33. package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
  34. package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
  35. package/plugins/trailhead/hooks/lib/model-defaults-review.js +399 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +489 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults.json +16 -0
  38. package/plugins/trailhead/hooks/trailhead-check-update.js +69 -14
  39. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
  40. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
  41. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
  42. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
  45. package/plugins/trailhead/skills/_shared/choices.md +2 -0
  46. package/plugins/trailhead/skills/_shared/configuration-reference.md +7 -4
  47. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  48. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  49. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  50. package/plugins/trailhead/skills/_shared/session-handoff.md +5 -5
  51. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -1
  52. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  53. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  54. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  55. package/plugins/trailhead/skills/_shared/techniques/prototype.md +5 -1
  56. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  57. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  58. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  59. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
  60. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  61. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
  62. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -1
  63. package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-mockup-link-guard.js: PreToolUse(AskUserQuestion) hook.
3
+ // Structural backstop for the Prototype technique's "surface the mockup" rule
4
+ // (_shared/techniques/prototype.md): a message that asks to approve, react to,
5
+ // or pick between mockups must carry each mockup's clickable link in its
6
+ // plain prose, right before the question. The prose rule alone was skipped in
7
+ // practice (an approval asked with only the project's name, no URL), so this
8
+ // hook blocks an AskUserQuestion that is a mockup-approval ask when the
9
+ // assistant prose immediately before it (no tool call in between) holds no
10
+ // URL or file path.
11
+ // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
12
+ // Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
13
+ // unexpected shape must never wedge the workflow.
14
+ // Claude-Code-specific (Codex has no AskUserQuestion tool; its twin there is
15
+ // trailhead-mockup-link-stop.js). Detection lives in lib/mockup-link.js.
16
+ // Pure functions are exported for tests; runs as a hook when executed directly.
17
+
18
+ const fs = require('fs');
19
+ const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
20
+
21
+ // Only the transcript tail matters: the prose sits right before the ask.
22
+ const TAIL_BYTES = 512 * 1024;
23
+
24
+ const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.message.content : []);
25
+ const isPendingAsk = (e) => e.type === 'assistant'
26
+ && blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
27
+
28
+ // The assistant prose right before the ask: walk the main-chain entries back
29
+ // from the end, skip the pending AskUserQuestion entries, and collect text
30
+ // blocks until anything else (a user turn, a tool result, another tool call).
31
+ function proseBeforeAsk(entries) {
32
+ const main = entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
33
+ let i = main.length - 1;
34
+ while (i >= 0 && isPendingAsk(main[i])) i--;
35
+ const texts = [];
36
+ for (; i >= 0; i--) {
37
+ const e = main[i];
38
+ if (e.type !== 'assistant') break;
39
+ const blocks = blocksOf(e);
40
+ if (blocks.some((b) => b.type === 'tool_use')) break;
41
+ blocks.filter((b) => b.type === 'text').forEach((b) => texts.unshift(b.text || ''));
42
+ }
43
+ return texts.join('\n');
44
+ }
45
+
46
+ function readTail(file) {
47
+ const fd = fs.openSync(file, 'r');
48
+ try {
49
+ const size = fs.fstatSync(fd).size;
50
+ const len = Math.min(size, TAIL_BYTES);
51
+ const buf = Buffer.alloc(len);
52
+ fs.readSync(fd, buf, 0, len, size - len);
53
+ return buf.toString('utf8');
54
+ } finally {
55
+ fs.closeSync(fd);
56
+ }
57
+ }
58
+
59
+ // Parse JSONL leniently: the first line of a tail read may be cut mid-record.
60
+ function parseEntries(text) {
61
+ return text.split('\n').flatMap((l) => {
62
+ try { return l.trim() ? [JSON.parse(l)] : []; } catch { return []; }
63
+ });
64
+ }
65
+
66
+ function block() {
67
+ process.stdout.write(JSON.stringify({ decision: 'block', code: 'MOCKUP_LINK_MISSING', reason: BLOCK_REASON }));
68
+ process.exit(2);
69
+ }
70
+
71
+ function run(data) {
72
+ try {
73
+ const payload = JSON.parse(data);
74
+ if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
75
+ if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
76
+ if (!payload.transcript_path) process.exit(0);
77
+ const prose = proseBeforeAsk(parseEntries(readTail(payload.transcript_path)));
78
+ if (!hasLink(prose)) block();
79
+ } catch {
80
+ // fall through to allow: a guard must never wedge the workflow
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ if (require.main === module) {
86
+ let data = '';
87
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
88
+ process.stdin.on('data', (c) => (data += c));
89
+ process.stdin.on('end', () => {
90
+ clearTimeout(timer);
91
+ run(data);
92
+ });
93
+ }
94
+
95
+ module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-mockup-link-guard.js. Run: node trailhead-mockup-link-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { execFileSync } = require('child_process');
9
+ const {
10
+ isMockupApprovalAsk,
11
+ hasLink,
12
+ proseBeforeAsk,
13
+ } = require('./trailhead-mockup-link-guard.js');
14
+
15
+ const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
16
+
17
+ let passed = 0;
18
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
19
+
20
+ // Costruttori di righe transcript (stesso shape JSONL di Claude Code)
21
+ const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
22
+ const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
23
+ const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id: 'x', name, input }] } });
24
+ const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
25
+ const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
26
+
27
+ const approvalInput = {
28
+ questions: [{
29
+ question: 'Approvi il mockup della pagina FAQ?',
30
+ header: 'Mockup',
31
+ multiSelect: false,
32
+ options: [{ label: 'Approvo', description: 'Procedi col codice UI' }, { label: 'Chiedo modifiche', description: '' }],
33
+ }],
34
+ };
35
+
36
+ // --- isMockupApprovalAsk ---
37
+ ok('IT mockup approval ask is detected', isMockupApprovalAsk(approvalInput));
38
+ ok('EN mockup approval ask is detected', isMockupApprovalAsk({
39
+ questions: [{ question: 'Do you approve the login mockup?', options: [{ label: 'Approve' }, { label: 'Request changes' }] }],
40
+ }));
41
+ ok('pick between prototype variants is detected', isMockupApprovalAsk({
42
+ questions: [{ question: 'Quale variante del prototipo scegli?', options: [{ label: 'Variante A' }, { label: 'Variante B' }] }],
43
+ }));
44
+ ok('"want mockups first?" offer is NOT an approval ask', !isMockupApprovalAsk({
45
+ questions: [{ question: 'Vuoi prima un mockup della schermata?', options: [{ label: 'Sì' }, { label: 'No' }] }],
46
+ }));
47
+ // Regressione da una sessione reale: l'offerta di un mockup cita una
48
+ // approvazione futura solo nella descrizione dell'opzione
49
+ ok('mockup offer whose option description mentions approval is NOT an approval ask', !isMockupApprovalAsk({
50
+ questions: [{
51
+ question: 'Vuoi cambiare qualcosa nello scope della pagina FAQ?',
52
+ header: 'Scope',
53
+ options: [
54
+ { label: 'Va bene così', description: 'Procedo con lo scope descritto' },
55
+ { label: 'Prima un mockup', description: 'Disegno la pagina su claude.ai/design e attendo approvazione prima di scriverla' },
56
+ ],
57
+ }],
58
+ }));
59
+ ok('mockup and approval words in different questions do not combine', !isMockupApprovalAsk({
60
+ questions: [
61
+ { question: 'Vuoi prima un mockup?', options: [{ label: 'Sì' }] },
62
+ { question: 'Approvi il piano?', options: [{ label: 'Sì' }] },
63
+ ],
64
+ }));
65
+ ok('unrelated ask is not detected', !isMockupApprovalAsk({
66
+ questions: [{ question: 'Approvi il piano?', options: [{ label: 'Sì' }, { label: 'No' }] }],
67
+ }));
68
+ ok('malformed input is not detected', !isMockupApprovalAsk({}) && !isMockupApprovalAsk(null));
69
+
70
+ // --- hasLink ---
71
+ ok('https URL counts as a link', hasLink('Ecco: https://claude.ai/design/p/abc?file=faq.html'));
72
+ ok('absolute html path counts as a link', hasLink('Mockup in /home/u/app/mockups/faq.html'));
73
+ ok('relative html path counts as a link', hasLink('apri `mockups/faq.html`'));
74
+ ok('markdown link to a file counts', hasLink('[faq](mockups/faq.html)'));
75
+ ok('project name alone is NOT a link', !hasLink('Il mockup della pagina FAQ è pronto su MyApp, gruppo Marketing.'));
76
+ ok('empty prose has no link', !hasLink(''));
77
+
78
+ // --- proseBeforeAsk ---
79
+ const failing = [
80
+ userPrompt('lavora #12'),
81
+ toolUse('Bash'),
82
+ toolResult('ok'),
83
+ thinking(),
84
+ text('Il mockup della pagina FAQ è pronto su MyApp, con indice sezioni.'),
85
+ toolUse('AskUserQuestion', approvalInput),
86
+ ];
87
+ ok('prose right before the ask is collected', /pronto su MyApp/.test(proseBeforeAsk(failing)));
88
+
89
+ const linkBeforeTools = [
90
+ userPrompt('lavora #12'),
91
+ text('Mockup: https://claude.ai/design/p/abc'),
92
+ toolUse('Bash'),
93
+ toolResult('ok'),
94
+ text('Pronto.'),
95
+ toolUse('AskUserQuestion', approvalInput),
96
+ ];
97
+ ok('a link before an intervening tool call does not count', !hasLink(proseBeforeAsk(linkBeforeTools)));
98
+
99
+ const sidechain = [
100
+ text('Mockup FAQ pronto.'),
101
+ { ...text('https://example.com/side'), isSidechain: true },
102
+ toolUse('AskUserQuestion', approvalInput),
103
+ ];
104
+ ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
105
+
106
+ const noAskYet = [userPrompt('x'), text('Ecco https://a.b/c')];
107
+ ok('works when the pending tool_use is not yet in the transcript', hasLink(proseBeforeAsk(noAskYet)));
108
+
109
+ // --- end-to-end ---
110
+ function writeTranscript(entries) {
111
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'th-mlg-'));
112
+ const f = path.join(dir, 't.jsonl');
113
+ fs.writeFileSync(f, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
114
+ return f;
115
+ }
116
+ function runHook(payload) {
117
+ try {
118
+ const out = execFileSync('node', [HOOK], { input: JSON.stringify(payload) });
119
+ return { code: 0, out: out.toString() };
120
+ } catch (e) {
121
+ return { code: e.status, out: (e.stdout || '').toString() };
122
+ }
123
+ }
124
+
125
+ const r1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(failing) });
126
+ ok('approval ask without a link in the prose is blocked (exit 2)', r1.code === 2 && /MOCKUP_LINK_MISSING/.test(r1.out));
127
+
128
+ const withLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/design/p/abc?file=faq.html'), failing[5]];
129
+ const r2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(withLink) });
130
+ ok('approval ask with a link in the prose is allowed', r2.code === 0 && r2.out.trim() === '');
131
+
132
+ const r3 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: '/nonexistent/t.jsonl' });
133
+ ok('missing transcript fails open', r3.code === 0);
134
+
135
+ const r4 = runHook({ tool_name: 'Bash', tool_input: { command: 'ls' }, transcript_path: writeTranscript(failing) });
136
+ ok('other tools are ignored', r4.code === 0);
137
+
138
+ const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
139
+ ok('non-mockup ask is allowed', r5.code === 0);
140
+
141
+ let r6;
142
+ try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
143
+ ok('unparseable input fails open', r6 === 0);
144
+
145
+ console.log(`trailhead-mockup-link-guard: ${passed} passed`);
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-mockup-link-stop.js: Stop hook (Codex).
3
+ // Codex twin of trailhead-mockup-link-guard.js. On Codex trailhead asks its
4
+ // questions as a plain-text numbered list (request_user_input is Plan-Mode
5
+ // only), so there is no tool call to gate: this hook reads the final reply
6
+ // (`last_assistant_message`) instead. When the reply asks to approve a mockup
7
+ // (or pick between mockup variants) and carries no URL or file path, it
8
+ // answers {"decision":"block","reason":...}: on Stop, Codex does not reject
9
+ // the turn but continues it, with the reason as the next prompt, so the agent
10
+ // rewrites the ask with the link.
11
+ // Never blocks a turn that was already continued (`stop_hook_active`), so it
12
+ // cannot loop. Fail-OPEN on any error (exit 0, no output).
13
+
14
+ const { hasLink, isMockupApprovalMessage, BLOCK_REASON } = require('./lib/mockup-link.js');
15
+
16
+ function run(data) {
17
+ try {
18
+ const payload = JSON.parse(data);
19
+ if (payload.stop_hook_active) process.exit(0);
20
+ const msg = payload.last_assistant_message;
21
+ if (isMockupApprovalMessage(msg) && !hasLink(msg)) {
22
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: BLOCK_REASON }));
23
+ }
24
+ } catch {
25
+ // fall through to allow: a guard must never wedge the workflow
26
+ }
27
+ process.exit(0);
28
+ }
29
+
30
+ if (require.main === module) {
31
+ let data = '';
32
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
33
+ process.stdin.on('data', (c) => (data += c));
34
+ process.stdin.on('end', () => {
35
+ clearTimeout(timer);
36
+ run(data);
37
+ });
38
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-mockup-link-stop.js (+ lib/mockup-link.js message detection).
3
+ // Run: node trailhead-mockup-link-stop.test.js
4
+ const assert = require('assert');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const { isMockupApprovalMessage } = require('./lib/mockup-link.js');
8
+
9
+ const HOOK = path.join(__dirname, 'trailhead-mockup-link-stop.js');
10
+
11
+ let passed = 0;
12
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
13
+
14
+ // Risposta Codex: la domanda è un elenco numerato in testo semplice
15
+ const noLink = [
16
+ 'Il mockup della pagina FAQ è pronto su MyApp, con indice sezioni.',
17
+ '',
18
+ 'Approvi il mockup della pagina FAQ?',
19
+ '1. Approvo',
20
+ '2. Chiedo modifiche',
21
+ ].join('\n');
22
+ const withLink = noLink.replace('pronto su MyApp', 'pronto: https://claude.ai/design/p/abc?file=faq.html');
23
+
24
+ // --- isMockupApprovalMessage ---
25
+ ok('IT approval reply is detected', isMockupApprovalMessage(noLink));
26
+ ok('EN approval reply is detected', isMockupApprovalMessage('Mockup ready.\n\nDo you approve the login mockup?\n1. Yes\n2. No'));
27
+ ok('variant pick is detected', isMockupApprovalMessage('Due prototipi pronti.\nQuale variante scegli?\n1. A\n2. B'));
28
+ ok('past approval mention is NOT an ask', !isMockupApprovalMessage('Ho implementato il mockup approvato. Commit fatto.'));
29
+ ok('approval word without a question line is NOT an ask', !isMockupApprovalMessage('Approvi il mockup quando vuoi.'));
30
+ ok('"want mockups first?" offer is NOT an ask', !isMockupApprovalMessage('Vuoi prima un mockup?\n1. Sì\n2. No'));
31
+ ok('non-string is not detected', !isMockupApprovalMessage(undefined));
32
+
33
+ // --- end-to-end ---
34
+ function runHook(payload) {
35
+ try {
36
+ const out = execFileSync('node', [HOOK], { input: typeof payload === 'string' ? payload : JSON.stringify(payload) });
37
+ return { code: 0, out: out.toString() };
38
+ } catch (e) {
39
+ return { code: e.status, out: (e.stdout || '').toString() };
40
+ }
41
+ }
42
+ const parse = (s) => { try { return JSON.parse(s); } catch { return null; } };
43
+
44
+ const r1 = runHook({ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: noLink });
45
+ const j1 = parse(r1.out);
46
+ ok('approval reply without a link: continues the turn with a block decision (exit 0, JSON)',
47
+ r1.code === 0 && j1 && j1.decision === 'block' && /mockup-link/.test(j1.reason));
48
+
49
+ const r2 = runHook({ hook_event_name: 'Stop', stop_hook_active: false, last_assistant_message: withLink });
50
+ ok('approval reply with a link is allowed', r2.code === 0 && r2.out.trim() === '');
51
+
52
+ const r3 = runHook({ hook_event_name: 'Stop', stop_hook_active: true, last_assistant_message: noLink });
53
+ ok('already-continued turn is never blocked again (no loop)', r3.code === 0 && r3.out.trim() === '');
54
+
55
+ const r4 = runHook({ hook_event_name: 'Stop', stop_hook_active: false });
56
+ ok('missing last_assistant_message fails open', r4.code === 0 && r4.out.trim() === '');
57
+
58
+ const r5 = runHook({ hook_event_name: 'Stop', last_assistant_message: 'Fatto, commit pushato.' });
59
+ ok('ordinary reply is allowed', r5.code === 0 && r5.out.trim() === '');
60
+
61
+ const r6 = runHook('not json');
62
+ ok('unparseable input fails open', r6.code === 0 && r6.out.trim() === '');
63
+
64
+ console.log(`trailhead-mockup-link-stop: ${passed} passed`);
@@ -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
- return /^\.env\..+$/.test(lower);
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
- return isSecretBasename(basename(bare));
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.