@marcomigozzi/trailhead 0.12.1 → 0.14.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 +38 -13
- package/README.md +48 -13
- package/bin/lib/codex-projection.js +18 -11
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +3 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -10,6 +10,8 @@ const {
|
|
|
10
10
|
isMockupApprovalAsk,
|
|
11
11
|
hasLink,
|
|
12
12
|
proseBeforeAsk,
|
|
13
|
+
pendingAskIndex,
|
|
14
|
+
askHasLink,
|
|
13
15
|
} = require('./trailhead-mockup-link-guard.js');
|
|
14
16
|
|
|
15
17
|
const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
|
|
@@ -20,7 +22,7 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
|
20
22
|
// Costruttori di righe transcript (stesso shape JSONL di Claude Code)
|
|
21
23
|
const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
|
|
22
24
|
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
|
|
25
|
+
const toolUse = (name, input = {}, id = 'x') => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id, name, input }] } });
|
|
24
26
|
const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
|
|
25
27
|
const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
|
|
26
28
|
|
|
@@ -52,7 +54,7 @@ ok('mockup offer whose option description mentions approval is NOT an approval a
|
|
|
52
54
|
header: 'Scope',
|
|
53
55
|
options: [
|
|
54
56
|
{ label: 'Va bene così', description: 'Procedo con lo scope descritto' },
|
|
55
|
-
{ label: 'Prima un mockup', description: 'Disegno la pagina su
|
|
57
|
+
{ label: 'Prima un mockup', description: 'Disegno la pagina su un canvas Artifacts e attendo approvazione prima di scriverla' },
|
|
56
58
|
],
|
|
57
59
|
}],
|
|
58
60
|
}));
|
|
@@ -68,7 +70,7 @@ ok('unrelated ask is not detected', !isMockupApprovalAsk({
|
|
|
68
70
|
ok('malformed input is not detected', !isMockupApprovalAsk({}) && !isMockupApprovalAsk(null));
|
|
69
71
|
|
|
70
72
|
// --- hasLink ---
|
|
71
|
-
ok('https URL counts as a link', hasLink('Ecco: https://claude.ai/
|
|
73
|
+
ok('https URL counts as a link', hasLink('Ecco: https://claude.ai/artifact/AbC123xyz'));
|
|
72
74
|
ok('absolute html path counts as a link', hasLink('Mockup in /home/u/app/mockups/faq.html'));
|
|
73
75
|
ok('relative html path counts as a link', hasLink('apri `mockups/faq.html`'));
|
|
74
76
|
ok('markdown link to a file counts', hasLink('[faq](mockups/faq.html)'));
|
|
@@ -88,7 +90,7 @@ ok('prose right before the ask is collected', /pronto su MyApp/.test(proseBefore
|
|
|
88
90
|
|
|
89
91
|
const linkBeforeTools = [
|
|
90
92
|
userPrompt('lavora #12'),
|
|
91
|
-
text('Mockup: https://claude.ai/
|
|
93
|
+
text('Mockup: https://claude.ai/artifact/AbC123xyz'),
|
|
92
94
|
toolUse('Bash'),
|
|
93
95
|
toolResult('ok'),
|
|
94
96
|
text('Pronto.'),
|
|
@@ -103,8 +105,45 @@ const sidechain = [
|
|
|
103
105
|
];
|
|
104
106
|
ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
// Senza ancora: comportamento storico, si parte dalla coda del transcript
|
|
109
|
+
const noAnchor = [userPrompt('x'), text('Ecco https://a.b/c')];
|
|
110
|
+
ok('without an anchor the prose is read from the transcript tail', hasLink(proseBeforeAsk(noAnchor)));
|
|
111
|
+
|
|
112
|
+
// Con ancora: si parte dall'ask trovato, non dalla coda
|
|
113
|
+
const anchored = [
|
|
114
|
+
userPrompt('x'),
|
|
115
|
+
text('Mockup: https://a.b/c'),
|
|
116
|
+
toolUse('AskUserQuestion', approvalInput, 'ask1'),
|
|
117
|
+
toolResult('Approvo'),
|
|
118
|
+
text('Altro testo senza link'),
|
|
119
|
+
];
|
|
120
|
+
ok('with an anchor the prose is read back from the anchored ask', hasLink(proseBeforeAsk(anchored, 2)));
|
|
121
|
+
|
|
122
|
+
// --- askHasLink ---
|
|
123
|
+
const askWith = (over) => ({ questions: [{ ...approvalInput.questions[0], ...over }] });
|
|
124
|
+
ok('link in the approval question counts', askHasLink(askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' })));
|
|
125
|
+
ok('link in the approval header counts', askHasLink(askWith({ header: 'https://claude.ai/artifact/AbC123' })));
|
|
126
|
+
ok('link in an approval option label counts', askHasLink(askWith({ options: [{ label: 'Apri https://a.b/c' }, { label: 'No' }] })));
|
|
127
|
+
ok('link in an approval option description counts', askHasLink(askWith({ options: [{ label: 'Approvo', description: 'vedi mockups/faq.html' }] })));
|
|
128
|
+
ok('approval ask without any link has none', !askHasLink(approvalInput));
|
|
129
|
+
ok('link only in a different non-approval question does not count', !askHasLink({
|
|
130
|
+
questions: [
|
|
131
|
+
{ question: 'Dove sta il piano?', options: [{ label: 'https://a.b/piano' }] },
|
|
132
|
+
approvalInput.questions[0],
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
ok('malformed input has no ask link', !askHasLink({}) && !askHasLink(null));
|
|
136
|
+
|
|
137
|
+
// --- pendingAskIndex ---
|
|
138
|
+
const withIds = [userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok'), text('Prosa'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
139
|
+
ok('pendingAskIndex finds the entry by tool_use id', pendingAskIndex(withIds, 'ask1', approvalInput) === 4);
|
|
140
|
+
ok('pendingAskIndex by id ignores a missing id', pendingAskIndex(withIds, 'nope', approvalInput) === -1);
|
|
141
|
+
ok('pendingAskIndex without id falls back to input equality on the tail', pendingAskIndex(withIds, undefined, approvalInput) === 4);
|
|
142
|
+
ok('pendingAskIndex without id is -1 when the tail ask has a different input',
|
|
143
|
+
pendingAskIndex(withIds, undefined, askWith({ question: 'Approvi il mockup B?' })) === -1);
|
|
144
|
+
ok('pendingAskIndex is -1 when the tail is the previous tool_result',
|
|
145
|
+
pendingAskIndex([userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok')], undefined, approvalInput) === -1);
|
|
146
|
+
ok('pendingAskIndex ignores sidechain entries', pendingAskIndex([{ ...toolUse('AskUserQuestion', approvalInput, 'ask1'), isSidechain: true }], 'ask1', approvalInput) === -1);
|
|
108
147
|
|
|
109
148
|
// --- end-to-end ---
|
|
110
149
|
function writeTranscript(entries) {
|
|
@@ -125,7 +164,7 @@ function runHook(payload) {
|
|
|
125
164
|
const r1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(failing) });
|
|
126
165
|
ok('approval ask without a link in the prose is blocked (exit 2)', r1.code === 2 && /MOCKUP_LINK_MISSING/.test(r1.out));
|
|
127
166
|
|
|
128
|
-
const withLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/
|
|
167
|
+
const withLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/artifact/AbC123xyz'), failing[5]];
|
|
129
168
|
const r2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(withLink) });
|
|
130
169
|
ok('approval ask with a link in the prose is allowed', r2.code === 0 && r2.out.trim() === '');
|
|
131
170
|
|
|
@@ -138,6 +177,39 @@ ok('other tools are ignored', r4.code === 0);
|
|
|
138
177
|
const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
|
|
139
178
|
ok('non-mockup ask is allowed', r5.code === 0);
|
|
140
179
|
|
|
180
|
+
// Shape reale di Claude Code 2.1.295: il messaggio corrente non e' ancora nel transcript
|
|
181
|
+
const unflushed = [userPrompt('lavora #12'), toolUse('Bash', {}, 'b1'), toolResult('ok')];
|
|
182
|
+
const ra = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(unflushed) });
|
|
183
|
+
ok('(a) transcript ending at a previous tool_result fails open', ra.code === 0 && ra.out.trim() === '');
|
|
184
|
+
|
|
185
|
+
const flushedNoLink = [...failing.slice(0, 5), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
186
|
+
const rb = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
187
|
+
ok('(b) tool_use_id with the ask entry present and no link in the prose is blocked', rb.code === 2 && /MOCKUP_LINK_MISSING/.test(rb.out));
|
|
188
|
+
|
|
189
|
+
const linkedAsk = askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' });
|
|
190
|
+
const rc = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: linkedAsk, transcript_path: writeTranscript(failing) });
|
|
191
|
+
ok('(c) link carried by the ask question is allowed', rc.code === 0 && rc.out.trim() === '');
|
|
192
|
+
|
|
193
|
+
const otherPending = [userPrompt('x'), text('Niente link'), toolUse('AskUserQuestion', { questions: [{ question: 'Altro?', options: [] }] }, 'other')];
|
|
194
|
+
const rd = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(otherPending) });
|
|
195
|
+
ok('(d) transcript ending in a different pending ask fails open', rd.code === 0 && rd.out.trim() === '');
|
|
196
|
+
|
|
197
|
+
// Testo e tool_use nella STESSA riga di transcript
|
|
198
|
+
const sameEntry = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }, { type: 'tool_use', id: 'ask1', name: 'AskUserQuestion', input: approvalInput }] } });
|
|
199
|
+
ok('text before the tool_use in the same entry counts as prose', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')], 1)));
|
|
200
|
+
ok('same-entry text is also read from the tail', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')])));
|
|
201
|
+
const rs1 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Vedi https://a.b/c')]) });
|
|
202
|
+
ok('same-entry text with a link is allowed', rs1.code === 0 && rs1.out.trim() === '');
|
|
203
|
+
const rs2 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Mockup pronto su MyApp')]) });
|
|
204
|
+
ok('same-entry text without a link is blocked', rs2.code === 2 && /MOCKUP_LINK_MISSING/.test(rs2.out));
|
|
205
|
+
|
|
206
|
+
// Senza tool_use_id: ask in coda identico a tool_input (fallback per uguaglianza)
|
|
207
|
+
const ri1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
208
|
+
ok('no id, identical tail ask, no link in the prose is blocked', ri1.code === 2 && /MOCKUP_LINK_MISSING/.test(ri1.out));
|
|
209
|
+
const flushedLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/artifact/AbC123xyz'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
210
|
+
const ri2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedLink) });
|
|
211
|
+
ok('no id, identical tail ask, link in the prose is allowed', ri2.code === 0 && ri2.out.trim() === '');
|
|
212
|
+
|
|
141
213
|
let r6;
|
|
142
214
|
try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
|
|
143
215
|
ok('unparseable input fails open', r6 === 0);
|
|
@@ -19,7 +19,7 @@ const noLink = [
|
|
|
19
19
|
'1. Approvo',
|
|
20
20
|
'2. Chiedo modifiche',
|
|
21
21
|
].join('\n');
|
|
22
|
-
const withLink = noLink.replace('pronto su MyApp', 'pronto: https://claude.ai/
|
|
22
|
+
const withLink = noLink.replace('pronto su MyApp', 'pronto: https://claude.ai/artifact/AbC123xyz');
|
|
23
23
|
|
|
24
24
|
// --- isMockupApprovalMessage ---
|
|
25
25
|
ok('IT approval reply is detected', isMockupApprovalMessage(noLink));
|
|
@@ -239,15 +239,26 @@ function detectSearchHygieneViolation(cmd) {
|
|
|
239
239
|
return null;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
function
|
|
242
|
+
function blockVerdict(hit) {
|
|
243
243
|
const reason =
|
|
244
244
|
"trailhead search-hygiene: a 'cd <dir>' (or pushd/env -C) followed by a relative " +
|
|
245
245
|
`${hit.verb} read (\`${hit.file}\`) makes the read target non-static, which trips the ` +
|
|
246
246
|
'Read() deny rule under bypass permissions (prompting for manual approval instead of ' +
|
|
247
247
|
'running headless). Pass explicit paths instead and never cd first: ' +
|
|
248
248
|
'grep -niE "<patterns>" <abs>/<file>, git -C <abs> ..., or use the Grep/Read tools.';
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
return { kind: 'block', payload: { decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
253
|
+
// exits or writes; any internal error falls through to allow.
|
|
254
|
+
function evaluate(cmd) {
|
|
255
|
+
try {
|
|
256
|
+
const hit = detectSearchHygieneViolation(cmd);
|
|
257
|
+
if (hit) return blockVerdict(hit);
|
|
258
|
+
} catch {
|
|
259
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
260
|
+
}
|
|
261
|
+
return { kind: 'allow' };
|
|
251
262
|
}
|
|
252
263
|
|
|
253
264
|
function run(data) {
|
|
@@ -257,11 +268,10 @@ function run(data) {
|
|
|
257
268
|
} catch {
|
|
258
269
|
process.exit(0); // unparseable input: nothing to gate
|
|
259
270
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
// fall through to allow: a guard must never wedge the workflow
|
|
271
|
+
const v = evaluate(cmd);
|
|
272
|
+
if (v.kind === 'block') {
|
|
273
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
274
|
+
process.exit(2);
|
|
265
275
|
}
|
|
266
276
|
process.exit(0);
|
|
267
277
|
}
|
|
@@ -276,4 +286,4 @@ if (require.main === module) {
|
|
|
276
286
|
});
|
|
277
287
|
}
|
|
278
288
|
|
|
279
|
-
module.exports = { detectSearchHygieneViolation };
|
|
289
|
+
module.exports = { detectSearchHygieneViolation, evaluate };
|
|
@@ -5,7 +5,8 @@ const assert = require('assert');
|
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
9
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-search-guard.js');
|
|
9
10
|
|
|
10
11
|
let passed = 0;
|
|
11
12
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|