@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.
- package/README.it.md +6 -3
- package/README.md +6 -3
- package/bin/lib/codex-projection.js +2 -1
- package/bin/lib/codex-projection.test.js +4 -1
- package/bin/trailhead.js +23 -13
- package/bin/trailhead.test.js +328 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/adopt.md +5 -1
- package/plugins/trailhead/commands/audit.md +10 -0
- package/plugins/trailhead/commands/auto.md +5 -1
- package/plugins/trailhead/commands/bug.md +5 -1
- package/plugins/trailhead/commands/config.md +5 -1
- package/plugins/trailhead/commands/dashboard.md +5 -1
- package/plugins/trailhead/commands/grill.md +5 -1
- package/plugins/trailhead/commands/idea.md +5 -1
- package/plugins/trailhead/commands/inbox.md +5 -1
- package/plugins/trailhead/commands/map.md +5 -1
- package/plugins/trailhead/commands/new.md +5 -1
- package/plugins/trailhead/commands/note.md +5 -1
- package/plugins/trailhead/commands/pause.md +5 -1
- package/plugins/trailhead/commands/prune.md +5 -1
- package/plugins/trailhead/commands/quick.md +6 -2
- package/plugins/trailhead/commands/resume.md +5 -1
- package/plugins/trailhead/commands/seed.md +5 -1
- package/plugins/trailhead/commands/split.md +5 -1
- package/plugins/trailhead/commands/ticket.md +5 -1
- package/plugins/trailhead/commands/todo.md +5 -1
- package/plugins/trailhead/commands/update.md +5 -1
- package/plugins/trailhead/commands/whiteboard.md +5 -1
- package/plugins/trailhead/commands/work.md +5 -1
- package/plugins/trailhead/hooks/hooks.json +9 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
- package/plugins/trailhead/hooks/lib/fixtures/label-guard-0.12.0.yml +55 -0
- package/plugins/trailhead/hooks/lib/label-guard-sync.test.js +156 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +56 -4
- package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
- package/plugins/trailhead/hooks/trailhead-check-update.js +25 -1
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
- package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -0
- package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +9 -2
- package/plugins/trailhead/skills/_shared/teamwork.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
- package/plugins/trailhead/skills/trailhead-manage/references/updating.md +4 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
- package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
- package/plugins/trailhead/templates/trailhead-label-guard-sync.js +212 -0
- 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
|
-
|
|
231
|
-
|
|
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
|
|
234
|
-
|
|
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`);
|