@marcomigozzi/trailhead 0.11.1 → 0.12.1

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 +23 -13
  6. package/bin/trailhead.test.js +328 -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/fixtures/label-guard-0.12.0.yml +55 -0
  35. package/plugins/trailhead/hooks/lib/label-guard-sync.test.js +156 -0
  36. package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +56 -4
  38. package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +25 -1
  40. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
  41. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
  42. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
  43. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
  45. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
  46. package/plugins/trailhead/skills/_shared/choices.md +2 -0
  47. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  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 +1 -1
  51. package/plugins/trailhead/skills/_shared/substrate-commands.md +9 -2
  52. package/plugins/trailhead/skills/_shared/teamwork.md +3 -1
  53. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  56. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  57. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
  58. package/plugins/trailhead/skills/trailhead-manage/references/updating.md +4 -0
  59. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  60. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
  61. package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
  62. package/plugins/trailhead/templates/trailhead-label-guard-sync.js +212 -0
  63. package/plugins/trailhead/templates/trailhead-label-guard.yml +5 -0
@@ -0,0 +1,55 @@
1
+ # trailhead label guard: repo-side enforcement.
2
+ # Copy this file to your repository's .github/workflows/ directory.
3
+ # It strips any `trailhead:*` label applied by an actor without write access,
4
+ # so external / triage users (and stray automations) can't inject tickets into
5
+ # the trailhead map. Maintainers (write/admin/maintain) apply labels normally.
6
+ #
7
+ # Optional: set a repo variable TRAILHEAD_LABEL_ALLOWLIST to a comma-separated
8
+ # list of extra logins that are always allowed (e.g. a bot account trailhead runs as).
9
+
10
+ name: trailhead label guard
11
+
12
+ on:
13
+ issues:
14
+ types: [labeled]
15
+
16
+ permissions:
17
+ issues: write
18
+
19
+ jobs:
20
+ guard:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/github-script@v7
24
+ env:
25
+ ALLOWLIST: ${{ vars.TRAILHEAD_LABEL_ALLOWLIST }}
26
+ with:
27
+ script: |
28
+ const label = context.payload.label && context.payload.label.name || '';
29
+ if (!label.startsWith('trailhead:')) return;
30
+
31
+ const actor = context.payload.sender.login;
32
+ const extra = (process.env.ALLOWLIST || '')
33
+ .split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
34
+ if (extra.includes(actor.toLowerCase())) return;
35
+
36
+ let permission = 'none';
37
+ try {
38
+ const r = await github.rest.repos.getCollaboratorPermissionLevel({
39
+ owner: context.repo.owner, repo: context.repo.repo, username: actor });
40
+ permission = r.data.permission; // admin | write | read | none (triage→read, maintain→write)
41
+ } catch (e) { /* not a collaborator → none */ }
42
+
43
+ if (['admin', 'write', 'maintain'].includes(permission)) return; // authorized
44
+
45
+ // Unauthorized: strip the reserved label and explain.
46
+ await github.rest.issues.removeLabel({
47
+ owner: context.repo.owner, repo: context.repo.repo,
48
+ issue_number: context.payload.issue.number, name: label,
49
+ }).catch(() => {});
50
+
51
+ await github.rest.issues.createComment({
52
+ owner: context.repo.owner, repo: context.repo.repo,
53
+ issue_number: context.payload.issue.number,
54
+ body: `⚠️ \`${label}\` is a **trailhead-reserved** label and was removed automatically: only repo maintainers (write access) apply \`trailhead:*\` labels. It was applied by @${actor}, who lacks write access. If this issue should join the trailhead map, a maintainer will adopt it.`,
55
+ }).catch(() => {});
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-label-guard.yml and
3
+ // templates/trailhead-label-guard-sync.js. Run: node label-guard-sync.test.js
4
+ // No framework: plain asserts, mirrors the style of commit-msg-sync.test.js.
5
+ const assert = require('assert');
6
+ const { execFileSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const templatesDir = path.resolve(__dirname, '..', '..', 'templates');
12
+ const scriptPath = path.join(templatesDir, 'trailhead-label-guard-sync.js');
13
+ const templatePath = path.join(templatesDir, 'trailhead-label-guard.yml');
14
+ const oldFixturePath = path.join(__dirname, 'fixtures', 'label-guard-0.12.0.yml');
15
+ const repoGuardPath = path.resolve(__dirname, '..', '..', '..', '..', '.github', 'workflows', 'trailhead-label-guard.yml');
16
+
17
+ const {
18
+ isTrailheadGuard, jobGuardIf, hasJobLevelFilter, classify, syncLabelGuard, sessionNotice, ackGuard, parseArgs, GUARD_REL, ACK_REL,
19
+ } = require(scriptPath);
20
+
21
+ let passed = 0;
22
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
23
+
24
+ const templateText = fs.readFileSync(templatePath, 'utf8');
25
+ const oldText = fs.readFileSync(oldFixturePath, 'utf8');
26
+
27
+ function mktmp() {
28
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-label-guard-sync-test-'));
29
+ }
30
+ function writeGuard(repoDir, text) {
31
+ const file = path.join(repoDir, GUARD_REL);
32
+ fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ fs.writeFileSync(file, text);
34
+ return file;
35
+ }
36
+
37
+ // --- il template filtra a livello di job (i job saltati non sono fatturati) ---
38
+ const cond = jobGuardIf(templateText);
39
+ ok('template: jobs.guard has a job-level if', cond !== null);
40
+ ok('template: job-level if filters on the trailhead: prefix',
41
+ cond.includes("startsWith(github.event.label.name, 'trailhead:')"));
42
+ ok('template: job-level if skips the repository owner',
43
+ cond.includes('github.event.sender.login != github.repository_owner'));
44
+ ok('template: job-level if skips allowlisted logins',
45
+ cond.includes("!contains(format(',{0},', vars.TRAILHEAD_LABEL_ALLOWLIST), format(',{0},', github.event.sender.login))"));
46
+ ok('template: the job-level if sits before runs-on',
47
+ templateText.indexOf(' if: >-') < templateText.indexOf(' runs-on: ubuntu-latest'));
48
+ ok('template: the in-script permission check is kept',
49
+ templateText.includes('getCollaboratorPermissionLevel') && templateText.includes("if (!label.startsWith('trailhead:')) return;"));
50
+ ok("template: the repo's own guard is byte-identical to the template",
51
+ fs.readFileSync(repoGuardPath, 'utf8') === templateText);
52
+
53
+ // --- riconoscimento e classificazione ----------------------------------------
54
+ ok('isTrailheadGuard: template recognised', isTrailheadGuard(templateText));
55
+ ok('isTrailheadGuard: 0.12.0 guard recognised', isTrailheadGuard(oldText));
56
+ ok('isTrailheadGuard: foreign workflow rejected', !isTrailheadGuard('name: ci\non: push\n'));
57
+ ok('jobGuardIf: 0.12.0 guard has no job-level if', jobGuardIf(oldText) === null);
58
+ ok('jobGuardIf: a step-level if does not count',
59
+ jobGuardIf('jobs:\n guard:\n runs-on: x\n steps:\n - if: startsWith(github.event.label.name, \'trailhead:\')\n') === null);
60
+ ok('jobGuardIf: an if on another job does not count',
61
+ jobGuardIf("jobs:\n guard:\n runs-on: x\n other:\n if: startsWith(github.event.label.name, 'trailhead:')\n") === null);
62
+ ok('hasJobLevelFilter: single-line if accepted',
63
+ hasJobLevelFilter("jobs:\n guard:\n if: startsWith(github.event.label.name, 'trailhead:')\n runs-on: x\n"));
64
+
65
+ ok('classify: missing file -> absent', classify({ existingText: null, templateText }) === 'absent');
66
+ ok('classify: template -> current', classify({ existingText: templateText, templateText }) === 'current');
67
+ ok('classify: CRLF template -> current', classify({ existingText: templateText.replace(/\n/g, '\r\n'), templateText }) === 'current');
68
+ ok('classify: 0.12.0 guard -> outdated', classify({ existingText: oldText, templateText }) === 'outdated');
69
+ ok('classify: filtered but edited -> custom',
70
+ classify({ existingText: templateText + '# local tweak\n', templateText }) === 'custom');
71
+ ok('classify: foreign workflow -> foreign', classify({ existingText: 'name: x\n', templateText }) === 'foreign');
72
+
73
+ // --- syncLabelGuard: check-only non scrive, --apply migra --------------------
74
+ {
75
+ const repo = mktmp();
76
+ const file = writeGuard(repo, oldText);
77
+ const r = syncLabelGuard({ repoDir: repo, templateText });
78
+ ok('sync check: outdated reported', r.status === 'outdated' && r.file === file);
79
+ ok('sync check: file untouched', fs.readFileSync(file, 'utf8') === oldText);
80
+ const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
81
+ ok('sync apply: outdated -> upgraded', a.status === 'upgraded');
82
+ ok('sync apply: file now equals the template', fs.readFileSync(file, 'utf8') === templateText);
83
+ ok('sync apply: no temp file left', fs.readdirSync(path.dirname(file)).length === 1);
84
+ ok('sync apply: second run -> current', syncLabelGuard({ repoDir: repo, templateText, apply: true }).status === 'current');
85
+ }
86
+ {
87
+ const repo = mktmp();
88
+ const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
89
+ ok('sync apply: absent -> installed', a.status === 'installed' && fs.readFileSync(a.file, 'utf8') === templateText);
90
+ }
91
+ for (const [label, text] of [['custom', templateText + '# local\n'], ['foreign', 'name: x\n']]) {
92
+ const repo = mktmp();
93
+ const file = writeGuard(repo, text);
94
+ const a = syncLabelGuard({ repoDir: repo, templateText, apply: true });
95
+ ok(`sync apply: ${label} left untouched`, a.status === label && fs.readFileSync(file, 'utf8') === text);
96
+ }
97
+
98
+ // --- offerta di sessione + ack ----------------------------------------------
99
+ {
100
+ const repo = mktmp();
101
+ const file = writeGuard(repo, oldText);
102
+ const notice = sessionNotice({ repoDir: repo, templateText, scriptPath: scriptPath });
103
+ ok('notice: outdated guard -> offer', notice !== null && notice.includes('--apply') && notice.includes('--ack') && notice.includes('job-level'));
104
+ ok('notice: asks the agent to explain why (billed minutes) in the question', notice.includes('put the reason in the question') && notice.includes('at least one minute') && notice.includes('skipped runs are free'));
105
+ ok('notice: read-only (no ack written)', !fs.existsSync(path.join(repo, ACK_REL)));
106
+ ok('notice: still offered on the next session', sessionNotice({ repoDir: repo, templateText, scriptPath }) !== null);
107
+ ok('ack: records the answer', ackGuard({ repoDir: repo }) === true && fs.existsSync(path.join(repo, ACK_REL)));
108
+ ok('notice: acked guard -> null', sessionNotice({ repoDir: repo, templateText, scriptPath }) === null);
109
+ fs.writeFileSync(file, oldText + '# edited\n');
110
+ ok('notice: comes back when the guard file changes', sessionNotice({ repoDir: repo, templateText, scriptPath }) !== null);
111
+ }
112
+ for (const [label, text] of [['current', templateText], ['custom', templateText + '# x\n'], ['foreign', 'name: x\n']]) {
113
+ const repo = mktmp();
114
+ writeGuard(repo, text);
115
+ ok(`notice: ${label} guard -> null`, sessionNotice({ repoDir: repo, templateText, scriptPath }) === null);
116
+ }
117
+ ok('notice: no guard -> null', sessionNotice({ repoDir: mktmp(), templateText, scriptPath }) === null);
118
+ ok('ack: no guard -> false', ackGuard({ repoDir: mktmp() }) === false);
119
+
120
+ // --- CLI ---------------------------------------------------------------------
121
+ ok('parseArgs: --repo needs a value', Boolean(parseArgs(['--repo']).error));
122
+ ok('parseArgs: unknown flag rejected', Boolean(parseArgs(['--nope']).error));
123
+ ok('parseArgs: --apply with --notice rejected', Boolean(parseArgs(['--notice', '--apply']).error));
124
+ ok('parseArgs: --notice with --ack rejected', Boolean(parseArgs(['--notice', '--ack']).error));
125
+ {
126
+ const repo = mktmp();
127
+ writeGuard(repo, oldText);
128
+ const out = JSON.parse(execFileSync('node', [scriptPath, '--repo', repo], { encoding: 'utf8' }));
129
+ ok('cli: outdated reported as JSON', out.status === 'outdated');
130
+ const applied = JSON.parse(execFileSync('node', [scriptPath, '--repo', repo, '--apply'], { encoding: 'utf8' }));
131
+ ok('cli: --apply upgrades', applied.status === 'upgraded');
132
+ }
133
+ {
134
+ const repo = mktmp();
135
+ writeGuard(repo, oldText);
136
+ ok('cli: --notice prints the offer', execFileSync('node', [scriptPath, '--repo', repo, '--notice'], { encoding: 'utf8' }).includes('outdated'));
137
+ execFileSync('node', [scriptPath, '--repo', repo, '--ack']);
138
+ ok('cli: --notice silent after --ack', execFileSync('node', [scriptPath, '--repo', repo, '--notice'], { encoding: 'utf8' }) === '');
139
+ }
140
+ // L'hook SessionStart porta l'offerta nel contesto della sessione.
141
+ {
142
+ const repo = mktmp();
143
+ execFileSync('git', ['init', '-q', repo]);
144
+ writeGuard(repo, oldText);
145
+ const hookPath = path.resolve(__dirname, '..', 'trailhead-check-update.js');
146
+ const run = () => execFileSync('node', [hookPath], {
147
+ input: JSON.stringify({ cwd: repo }), encoding: 'utf8',
148
+ env: { ...process.env, XDG_CACHE_HOME: path.join(repo, '.cache'), CLAUDE_CONFIG_DIR: path.join(repo, '.cfg') },
149
+ });
150
+ const ctx = (s) => (s ? JSON.parse(s).hookSpecificOutput.additionalContext : '');
151
+ ok('hook: SessionStart surfaces the label guard offer', ctx(run()).includes('label guard in this repo'));
152
+ ackGuard({ repoDir: repo });
153
+ ok('hook: no offer once acked', !ctx(run()).includes('label guard in this repo'));
154
+ }
155
+
156
+ console.log(`label-guard-sync: ${passed} passed`);
@@ -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
  }
@@ -163,6 +163,29 @@ function reviewNotice(cwd) {
163
163
  }
164
164
  }
165
165
 
166
+ // Offerta di upgrade del label guard del repo (stesso schema della review dei
167
+ // modelli): solo quando il guard committato è la versione senza `if:` a
168
+ // livello di job e l'utente non ha già risposto per quel file. Lo script di
169
+ // sync vive in templates/: ../templates per plugin, dev e Codex, la copia npm
170
+ // sotto <configDir>/trailhead/templates. Sola lettura; ogni errore -> null.
171
+ function labelGuardNotice(cwd) {
172
+ try {
173
+ const candidates = [
174
+ path.join(__dirname, '..', 'templates'),
175
+ path.join(configDir(), 'trailhead', 'templates'),
176
+ ];
177
+ const dir = candidates.find((d) => fs.existsSync(path.join(d, 'trailhead-label-guard-sync.js')));
178
+ if (!dir) return null;
179
+ const repoDir = run('git', ['-C', cwd, 'rev-parse', '--show-toplevel']);
180
+ if (!repoDir) return null;
181
+ const scriptPath = path.join(dir, 'trailhead-label-guard-sync.js');
182
+ const templateText = fs.readFileSync(path.join(dir, 'trailhead-label-guard.yml'), 'utf8');
183
+ return require(scriptPath).sessionNotice({ repoDir, templateText, scriptPath }) || null;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
166
189
  // Legge il JSON di SessionStart da stdin in modo asincrono, con timeout, come
167
190
  // gli altri hook: una lettura sincrona di fd 0 si bloccherebbe per sempre su
168
191
  // un TTY o su una pipe mai chiusa. Su TTY o input assente si prosegue con {}.
@@ -190,9 +213,10 @@ function main(input) {
190
213
  const cwd = (input && typeof input.cwd === 'string' && input.cwd) || process.cwd();
191
214
 
192
215
  const review = reviewNotice(cwd);
216
+ const guard = labelGuardNotice(cwd);
193
217
  const update = updateNotice();
194
218
 
195
- const parts = [review, update].filter(Boolean);
219
+ const parts = [review, guard, update].filter(Boolean);
196
220
  if (!parts.length) return;
197
221
 
198
222
  process.stdout.write(JSON.stringify({
@@ -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`);