@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.
Files changed (56) 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 +23 -13
  6. package/bin/trailhead.test.js +307 -0
  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.test.js +56 -4
  36. package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
  38. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
  39. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
  40. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
  41. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
  43. package/plugins/trailhead/skills/_shared/choices.md +2 -0
  44. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  45. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -1
  48. package/plugins/trailhead/skills/_shared/teamwork.md +1 -1
  49. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  50. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  51. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  52. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  53. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
  54. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  55. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
  56. package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
@@ -0,0 +1,50 @@
1
+ // mockup-link.js: shared detection for the mockup-link guards.
2
+ // Consumers: trailhead-mockup-link-guard.js (Claude Code, PreToolUse on
3
+ // AskUserQuestion) and trailhead-mockup-link-stop.js (Codex, Stop on the
4
+ // final reply, where an ask is a plain-text numbered list, not a tool call).
5
+ // Pure functions, no dependencies.
6
+
7
+ const MOCKUP_WORD = /mock-?ups?|prototip|prototype|wireframe/i;
8
+ // The ask itself, second person (IT + EN): "approvi", "do you approve",
9
+ // "accetti", "quale variante". Kept narrow on purpose: "il mockup approvato"
10
+ // (a past approval) and the "want mockups first?" offer are not asks.
11
+ const APPROVAL_WORD = /\bapprovi\b|\bapprove\b|\baccetti\b|\bquale variante\b|\bwhich variant\b/i;
12
+
13
+ const URL_RE = /https?:\/\/\S+/i;
14
+ const FILE_RE = /[\w~.\-/]+\.(?:html?|png|jpe?g|svg|webp|gif|pdf)\b/i;
15
+
16
+ function hasLink(prose) {
17
+ return typeof prose === 'string' && (URL_RE.test(prose) || FILE_RE.test(prose));
18
+ }
19
+
20
+ // One question's question, header and option labels. Option descriptions are
21
+ // left out: an offer like "Prima un mockup — attendo approvazione" describes
22
+ // a future approval, it does not ask for one.
23
+ function questionText(q) {
24
+ const labels = Array.isArray(q.options) ? q.options.map((o) => o && o.label) : [];
25
+ return [q.question, q.header, ...labels].filter((s) => typeof s === 'string').join('\n');
26
+ }
27
+
28
+ // AskUserQuestion input: does any single question ask to approve a mockup?
29
+ function isMockupApprovalAsk(input) {
30
+ const qs = (input && Array.isArray(input.questions)) ? input.questions : [];
31
+ return qs.some((q) => {
32
+ const t = q ? questionText(q) : '';
33
+ return MOCKUP_WORD.test(t) && APPROVAL_WORD.test(t);
34
+ });
35
+ }
36
+
37
+ // A plain-text reply (Codex): it talks about a mockup and one of its
38
+ // question lines (a line holding "?") asks for the approval.
39
+ function isMockupApprovalMessage(text) {
40
+ if (typeof text !== 'string' || !MOCKUP_WORD.test(text)) return false;
41
+ return text.split('\n').some((l) => l.includes('?') && APPROVAL_WORD.test(l));
42
+ }
43
+
44
+ const BLOCK_REASON =
45
+ 'trailhead mockup-link: this asks to approve/react to a mockup, but the message carries ' +
46
+ 'no link to it. Write each mockup\'s clickable link (URL or file path, one line per ' +
47
+ 'screen/variant) in the plain prose immediately before the question, not the project ' +
48
+ 'name, then ask again. See "Surface the mockup" in _shared/techniques/prototype.md.';
49
+
50
+ module.exports = { hasLink, isMockupApprovalAsk, isMockupApprovalMessage, BLOCK_REASON };
@@ -215,6 +215,20 @@ ok('reviewOffer: no ack, no stale pins -> null',
215
215
  ok('CLI with an unknown subcommand still exits 0', status === 0);
216
216
  }
217
217
 
218
+ // --- helper test-only: compatibilita' del `since` con la versione ------------
219
+ // Vero se base e' esattamente il prossimo bump patch, minor o major di current.
220
+ function isNextBump(current, since) {
221
+ const [a, b, c] = current.split('.').map(Number);
222
+ return [`${a}.${b}.${c + 1}`, `${a}.${b + 1}.0`, `${a + 1}.0.0`].includes(since);
223
+ }
224
+ function sinceCompatible(since, version) {
225
+ return since === version || semverLt(since, version) || isNextBump(version, since);
226
+ }
227
+ ok('isNextBump accepts the next patch, minor and major of 0.11.1',
228
+ ['0.11.2', '0.12.0', '1.0.0'].every((v) => isNextBump('0.11.1', v)));
229
+ ok('isNextBump rejects skipped bumps of 0.11.1',
230
+ ['0.13.0', '1.12.0', '0.11.3', '0.12.1'].every((v) => !isNextBump('0.11.1', v)));
231
+
218
232
  // --- shipped data invariants ---------------------------------------------
219
233
  {
220
234
  const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', 'package.json'), 'utf8'));
@@ -227,11 +241,13 @@ ok('reviewOffer: no ack, no stale pins -> null',
227
241
  const complete = data.history.every((e) => ['claude', 'codex'].every((h) =>
228
242
  ['strong', 'standard', 'fast'].every((t) => typeof e[h][t] === 'string' && e[h][t].length > 0)));
229
243
  ok('shipped data: every entry is a complete snapshot (3 tiers x 2 hosts)', complete);
230
- ok('shipped data: last since <= package.json version',
231
- cur.since === pkg.version || semverLt(cur.since, pkg.version));
244
+ // Il `since` dell'ultima entry puo' anticipare la release: uguale, piu' vecchio
245
+ // o esattamente il prossimo bump patch/minor/major della versione corrente.
246
+ ok('shipped data: last since matches or precedes package.json version (or is its next bump)',
247
+ sinceCompatible(cur.since, pkg.version));
232
248
  const plugin = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', '.claude-plugin', 'plugin.json'), 'utf8'));
233
- ok('shipped data: last since <= plugin.json version',
234
- cur.since === plugin.version || semverLt(cur.since, plugin.version));
249
+ ok('shipped data: last since matches or precedes plugin.json version (or is its next bump)',
250
+ sinceCompatible(cur.since, plugin.version));
235
251
  }
236
252
 
237
253
  {
@@ -241,6 +257,42 @@ ok('reviewOffer: no ack, no stale pins -> null',
241
257
  refDoc.includes(cur.claude.strong));
242
258
  ok('drift guard: current claude standard id appears in the Tier-class -> id section',
243
259
  refDoc.includes(cur.claude.standard));
260
+ ok('drift guard: current claude fast id appears in the Tier-class -> id section',
261
+ refDoc.includes(cur.claude.fast));
262
+ }
263
+
264
+ // --- default fast Haiku 5.5 dalla 0.12.0 -----------------------------------
265
+ ok('current claude fast default is claude-haiku-5-5', cur.claude.fast === 'claude-haiku-5-5');
266
+ ok('current opus/sonnet and codex tiers are unchanged',
267
+ cur.claude.strong === 'claude-opus-5-5' && cur.claude.standard === 'claude-sonnet-5-5' &&
268
+ cur.codex.strong === 'gpt-5.6-sol' && cur.codex.standard === 'gpt-5.6-terra' && cur.codex.fast === 'gpt-5.6-luna');
269
+
270
+ for (const old of ['claude-haiku-4-5-20251001', 'claude-haiku-4-5']) {
271
+ const stale = stalePins({ models: { 'codebase-map': old } }, data);
272
+ ok(`stale haiku pin ${old} detected`, stale.length === 1 &&
273
+ stale[0].key === 'models.codebase-map' && stale[0].from === old && stale[0].to === 'claude-haiku-5-5');
274
+ }
275
+ ok('claude-haiku-5-5 itself is not stale',
276
+ stalePins({ models: { 'codebase-map': 'claude-haiku-5-5' } }, data).length === 0);
277
+
278
+ {
279
+ const config = { models: { 'codebase-map': 'claude-haiku-4-5-20251001' } };
280
+ const offer = reviewOffer({ config, data, ack: '0.11.0' });
281
+ ok('reviewOffer: ack 0.11.0 with a Haiku 4.5 pin -> offer naming 0.12.0 and the line',
282
+ offer !== null && offer.notice.includes('0.12.0') &&
283
+ offer.notice.includes('models.codebase-map: claude-haiku-4-5-20251001 -> claude-haiku-5-5'));
284
+ ok('reviewOffer: ack 0.12.0 with a Haiku 4.5 pin -> null', reviewOffer({ config, data, ack: '0.12.0' }) === null);
285
+ }
286
+
287
+ {
288
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-mdr-haiku-'));
289
+ fs.mkdirSync(path.join(tmp, '.trailhead'));
290
+ const cfgPath = path.join(tmp, '.trailhead', 'config.json');
291
+ fs.writeFileSync(cfgPath, JSON.stringify({ models: { 'codebase-map': 'claude-haiku-4-5-20251001' } }));
292
+ const notice = check(tmp);
293
+ ok('check(): a Haiku 4.5 project pin is listed with its project-config source',
294
+ typeof notice === 'string' && notice.includes(
295
+ `models.codebase-map: claude-haiku-4-5-20251001 -> claude-haiku-5-5 (new default; from the project config, ${cfgPath})`));
244
296
  }
245
297
 
246
298
  // --- effectiveModels (pure merge, no mutation) --------------------------------
@@ -7,6 +7,10 @@
7
7
  { "since": "0.11.0",
8
8
  "claude": { "strong": "claude-opus-5-5", "standard": "claude-sonnet-5-5", "fast": "claude-haiku-4-5-20251001" },
9
9
  "codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" },
10
- "supersedes": { "claude": { "strong": ["claude-opus-5"], "standard": ["claude-sonnet-4-8"] } } }
10
+ "supersedes": { "claude": { "strong": ["claude-opus-5"], "standard": ["claude-sonnet-4-8"] } } },
11
+ { "since": "0.12.0",
12
+ "claude": { "strong": "claude-opus-5-5", "standard": "claude-sonnet-5-5", "fast": "claude-haiku-5-5" },
13
+ "codex": { "strong": "gpt-5.6-sol", "standard": "gpt-5.6-terra", "fast": "gpt-5.6-luna" },
14
+ "supersedes": { "claude": { "fast": ["claude-haiku-4-5-20251001", "claude-haiku-4-5"] } } }
11
15
  ]
12
16
  }
@@ -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`);