@marcomigozzi/trailhead 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -7,8 +7,9 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
  const os = require('os');
9
9
 
10
- const HOOK = path.join(__dirname, 'trailhead-body-guard.js');
11
- const { ghBodyWrite, isEmptyBody } = require('./trailhead-body-guard.js');
10
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
11
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-body-guard.js');
12
+ const { ghBodyWrite, isEmptyBody, evaluate } = require('./trailhead-body-guard.js');
12
13
 
13
14
  let passed = 0;
14
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -87,6 +88,10 @@ ok("isEmptyBody('x') is false", isEmptyBody('x') === false);
87
88
  ok("isEmptyBody(' a ') is false", isEmptyBody(' a ') === false);
88
89
 
89
90
  // --- end-to-end: block ---
91
+ // #219: a quoted command word or subcommand is the same word for bash.
92
+ const bQuoted = runHook('gh "issue" edit 5 --body ""');
93
+ ok('blocks an empty --body behind a quoted subcommand (#219)', bQuoted.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bQuoted.out));
94
+
90
95
  const b1 = runHook('gh issue edit 5 --body ""');
91
96
  ok('blocks an empty inline --body (exit 2)', b1.code === 2 && /"decision":"block"/.test(b1.out) && /EMPTY_ISSUE_BODY_WRITE/.test(b1.out));
92
97
 
@@ -206,9 +211,144 @@ const a2 = runHook('gh issue view 5 --json body'); // read, not a write
206
211
  ok('allows a read (exit 0)', a2.code === 0 && a2.out.trim() === '');
207
212
  const a3 = runHook('gh issue comment 5 --body ""'); // excluded verb, even though empty
208
213
  ok('allows an empty gh issue comment (excluded verb, exit 0)', a3.code === 0 && a3.out.trim() === '');
209
- const a4 = runHook('gh issue edit 5 --body "$(cat f)"'); // unknowable, can't prove empty
214
+ // The merged runner also runs the secret-guard, which resolves `$(cat <path>)`: point it at a real clean file.
215
+ const a4File = path.join(os.tmpdir(), `bg-subst-${process.pid}.md`);
216
+ fs.writeFileSync(a4File, 'not empty\n');
217
+ const a4 = runHook(`gh issue edit 5 --body "$(cat ${a4File})"`); // unknowable, can't prove empty
218
+ fs.unlinkSync(a4File);
210
219
  ok('allows an unknowable (shell-expansion) body (exit 0)', a4.code === 0 && a4.out.trim() === '');
211
220
 
221
+ // --- regression (#211): an empty-body write chained after another command ---
222
+ const isBlock = (r) => r.code === 2 && /"decision":"block"/.test(r.out) && /EMPTY_ISSUE_BODY_WRITE/.test(r.out);
223
+ const isAllow = (r) => r.code === 0 && r.out.trim() === '';
224
+ ok('blocks an empty --body chained after cd (exit 2)', isBlock(runHook('cd /tmp && gh issue edit 5 --body ""')));
225
+ ok('blocks an empty --body after a variable assignment (exit 2)', isBlock(runHook('D=x && gh issue edit 5 --body ""')));
226
+ ok('blocks an empty --body inside a subshell (exit 2)', isBlock(runHook('( gh issue edit 5 --body "" )')));
227
+ ok('blocks an empty --body inside if/then (exit 2)', isBlock(runHook('if true; then gh issue edit 5 --body ""; fi')));
228
+ ok('blocks an empty write that is a LATER statement after a non-empty one (exit 2)',
229
+ isBlock(runHook('gh issue edit 5 --body "x"; gh issue edit 6 --body ""')));
230
+ ok('blocks a chained gh api -f body="" after cd (exit 2)',
231
+ isBlock(runHook('cd /tmp && gh api --method PATCH repos/o/r/issues/5 -f body=""')));
232
+ ok('allows a chained non-empty body (exit 0)', isAllow(runHook('cd /tmp && gh issue edit 5 --body "x"')));
233
+ ok('allows a chained empty gh issue comment (out of scope, exit 0)',
234
+ isAllow(runHook('cd /tmp && gh issue comment 5 --body ""')));
235
+ ok('allows a chained empty gh issue create (out of scope, exit 0)',
236
+ isAllow(runHook('cd /tmp && gh issue create --title t --body ""')));
237
+ {
238
+ // After a cd the working directory is unknown: a relative path cannot be proven empty.
239
+ const relName = `bg-rel-${process.pid}.md`;
240
+ fs.writeFileSync(path.join(process.cwd(), relName), '');
241
+ // evaluate() directly: the merged runner's secret-guard has its own (separate) block for this shape.
242
+ ok('allows a RELATIVE --body-file after cd (unknowable)',
243
+ evaluate(`cd /tmp && gh issue edit 5 --body-file ${relName}`).kind === 'allow');
244
+ ok('allows a RELATIVE api body=@path after cd (unknowable)',
245
+ evaluate(`cd /tmp && gh api --method PATCH repos/o/r/issues/5 -F body=@${relName}`).kind === 'allow');
246
+ ok('blocks the same RELATIVE --body-file when no cd precedes it',
247
+ evaluate(`gh issue edit 5 --body-file ${relName}`).kind === 'block');
248
+ fs.unlinkSync(path.join(process.cwd(), relName));
249
+ const absEmpty = path.join(os.tmpdir(), `bg-abs-empty-${process.pid}.md`);
250
+ fs.writeFileSync(absEmpty, '');
251
+ ok('blocks an ABSOLUTE empty --body-file after cd (exit 2)',
252
+ isBlock(runHook(`cd /tmp && gh issue edit 5 --body-file ${absEmpty}`)));
253
+ fs.unlinkSync(absEmpty);
254
+ }
255
+
256
+ {
257
+ // Partial install (no secret-guard next to the hook): the leading write still blocks.
258
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-partial-'));
259
+ fs.mkdirSync(path.join(dir, 'lib'));
260
+ fs.copyFileSync(path.join(__dirname, 'trailhead-body-guard.js'), path.join(dir, 'trailhead-body-guard.js'));
261
+ fs.copyFileSync(path.join(__dirname, 'lib', 'gh-subcommand.js'), path.join(dir, 'lib', 'gh-subcommand.js'));
262
+ fs.copyFileSync(path.join(__dirname, 'lib', 'body-sources.js'), path.join(dir, 'lib', 'body-sources.js'));
263
+ const partial = require(path.join(dir, 'trailhead-body-guard.js'));
264
+ ok('without secret-guard, a leading empty --body still blocks',
265
+ partial.evaluate('gh issue edit 5 --body ""').kind === 'block');
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+
269
+ // --- regression (#213): repeated / mixed body flags resolve like gh (pflag keeps the LAST) ---
270
+ {
271
+ const blk = (c) => evaluate(c).kind === 'block';
272
+ const alw = (c) => evaluate(c).kind === 'allow';
273
+ for (const sub of ['issue', 'pr']) {
274
+ const g = `gh ${sub} edit 5`;
275
+ ok(`#213 ${sub}: blocks --body "x" --body ""`, blk(`${g} --body "x" --body ""`));
276
+ ok(`#213 ${sub}: blocks --body=x --body=`, blk(`${g} --body=x --body=`));
277
+ ok(`#213 ${sub}: blocks -b "x" --body ""`, blk(`${g} -b "x" --body ""`));
278
+ ok(`#213 ${sub}: blocks -b "x" -b ""`, blk(`${g} -b "x" -b ""`));
279
+ ok(`#213 ${sub}: allows --body "" --body "x"`, alw(`${g} --body "" --body "x"`));
280
+ ok(`#213 ${sub}: allows --body= --body=x`, alw(`${g} --body= --body=x`));
281
+ ok(`#213 ${sub}: allows --body "" -b "x"`, alw(`${g} --body "" -b "x"`));
282
+ ok(`#213 ${sub}: allows --body '' --body 'x'`, alw(`${g} --body '' --body 'x'`));
283
+ }
284
+ const g = 'gh issue edit 5';
285
+ ok('#213 blocks --body "x" --body " "', blk(`${g} --body "x" --body " "`));
286
+ ok('#213 blocks an attached -b""', blk(`${g} -b""`));
287
+ ok('#213 allows --body "" --body "$(cat f)" (unknowable last)', alw(`${g} --body "" --body "$(cat f)"`));
288
+ ok('#213 allows --body "" --body $B (unknowable last)', alw(`${g} --body "" --body $B`));
289
+ ok('#213 allows --body "" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "" --body-file /dev/null`));
290
+ ok('#213 allows --body "x" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "x" --body-file /dev/null`));
291
+ ok('#213 allows --body "x" --body-file "" (empty file flag = absent)', alw(`${g} --body "x" --body-file ""`));
292
+ ok('#213 blocks --body "" --body-file "" (empty file flag = absent)', blk(`${g} --body "" --body-file ""`));
293
+ const full = path.join(os.tmpdir(), `bg-213-full-${process.pid}.md`);
294
+ fs.writeFileSync(full, 'real content\n');
295
+ ok('#213 blocks --body-file <full> --body-file /dev/null (last wins)', blk(`${g} --body-file ${full} --body-file /dev/null`));
296
+ ok('#213 allows --body-file /dev/null --body-file <full> (last wins)', alw(`${g} --body-file /dev/null --body-file ${full}`));
297
+ fs.unlinkSync(full);
298
+ ok('#213 per statement: blocks a later repeated-flag empty write',
299
+ blk('gh issue edit 5 --body "x" --body ""; gh issue edit 6 --body "x"'));
300
+ ok('#213 per statement: allows when the last --body of each statement is non-empty',
301
+ alw('gh issue edit 5 --body "" --body "x"; gh issue edit 6 --body "ok"'));
302
+ }
303
+
304
+ // --- regression (#213 review): backslash-newline continuations, stdin-like body files ---
305
+ {
306
+ const blk = (c) => evaluate(c).kind === 'block';
307
+ const alw = (c) => evaluate(c).kind === 'allow';
308
+ const { words } = require('./lib/body-sources.js');
309
+ ok('words: unindented continuation joins (a\\<nl>b -> ab)', JSON.stringify(words('a\\\nb')) === '["ab"]');
310
+ ok('words: spaced continuation keeps two words', JSON.stringify(words('a \\\nb')) === '["a","b"]');
311
+ ok('words: continuation inside double quotes is removed', JSON.stringify(words('"a\\\nb"')) === JSON.stringify(['"ab"']));
312
+ ok('words: continuation inside single quotes is literal', JSON.stringify(words("'a\\\nb'")) === JSON.stringify(["'a\\\nb'"]));
313
+ ok('#213 blocks --title t \\<nl>--body ""', blk('gh issue edit 5 --title t \\\n--body ""'));
314
+ ok('#213 blocks edit 5 \\<nl>--body ""', blk('gh issue edit 5 \\\n--body ""'));
315
+ ok('#213 blocks --body "x" \\<nl>--body ""', blk('gh issue edit 5 --body "x" \\\n--body ""'));
316
+ ok('#213 allows --body "" \\<nl>--body "x"', alw('gh issue edit 5 --body "" \\\n--body "x"'));
317
+ for (const f of ['/dev/stdin', '/dev/fd/0', '/proc/self/fd/0', '-']) {
318
+ const r = ghBodyWrite(`gh issue edit 5 --body-file ${f}`);
319
+ ok(`#213 --body-file ${f} is unknowable`, r && r.body === null);
320
+ }
321
+ // Run only this guard as a real hook process behind a shell pipe (stdin is a consumed pipe, like in production).
322
+ const bodyGuardOnly = (c) => {
323
+ try {
324
+ execFileSync('sh', ['-c', 'printf %s "$TH_IN" | "$TH_NODE" "$TH_HOOK"'], {
325
+ env: { ...process.env, TH_IN: JSON.stringify({ tool_input: { command: c } }), TH_NODE: process.execPath, TH_HOOK: path.join(__dirname, 'trailhead-body-guard.js') },
326
+ });
327
+ return true;
328
+ } catch { return false; }
329
+ };
330
+ ok('#213 negative control: the bodyGuardOnly harness sees a block', !bodyGuardOnly('gh issue edit 5 --body ""'));
331
+ {
332
+ // `..` after a symlink resolves in the kernel, not lexically: lnk/s -> real/sub, so lnk/s/../empty.md is real/empty.md.
333
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-213-lnk-'));
334
+ fs.mkdirSync(path.join(d, 'real', 'sub'), { recursive: true });
335
+ fs.mkdirSync(path.join(d, 'lnk'));
336
+ fs.writeFileSync(path.join(d, 'real', 'empty.md'), '');
337
+ fs.writeFileSync(path.join(d, 'lnk', 'empty.md'), 'not empty\n');
338
+ fs.symlinkSync(path.join(d, 'real', 'sub'), path.join(d, 'lnk', 's'));
339
+ ok('#213 blocks a --body-file whose .. follows a symlink (read the raw path)',
340
+ blk(`gh issue edit 5 --body-file ${d}/lnk/s/../empty.md`));
341
+ fs.rmSync(d, { recursive: true, force: true });
342
+ }
343
+ for (const f of ['/dev//stdin', '/dev/fd//0', '/dev/./stdin']) {
344
+ ok(`#213 allows --body-file ${f} (non-canonical stdin spelling)`, bodyGuardOnly(`gh issue edit 5 --body-file ${f}`)); // the secret-guard judges these spellings on its own
345
+ }
346
+ ok('#213 pins: an escaped backslash before the newline is not a continuation (allows)',
347
+ alw('gh issue edit 5 --title t \\\\\n--body ""'));
348
+ ok('#213 pins: backslash CR LF is not a continuation (allows)',
349
+ alw('gh issue edit 5 --title t \\\r\n--body ""'));
350
+ }
351
+
212
352
  // --- crash-safety ---
213
353
  try {
214
354
  execFileSync('node', [HOOK], { input: 'not json at all {{{' });
@@ -219,4 +359,85 @@ try {
219
359
  const c2 = runHook('gh issue edit 5 --add-label x'); // non-body gh command
220
360
  ok('a non-body gh command exits 0', c2.code === 0 && c2.out.trim() === '');
221
361
 
362
+ // #225: a flag between the subcommand and the verb must not hide the write.
363
+ const bFlagVerb = runHook('gh issue -R o/r edit 5 --body ""');
364
+ ok('blocks an empty --body with a flag before the verb (exit 2)', bFlagVerb.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bFlagVerb.out));
365
+
366
+ // #227: constructs the guard cannot interpret are blocked, not emulated.
367
+ {
368
+ const CODE = 'BODY_GUARD_UNMODELLED_CONSTRUCT';
369
+ const clean = path.join(os.tmpdir(), `bg-um-clean-${process.pid}.md`);
370
+ fs.writeFileSync(clean, 'A real body.\n');
371
+ const code = (c) => { const r = runHook(c); return r.code === 2 ? (JSON.parse(r.out).code || '?') : null; };
372
+
373
+ // Gaps that used to pass.
374
+ for (const c of [
375
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`,
376
+ 'gh api -X PATCH repos/o/r/issues/1 -f bo""dy=""',
377
+ 'printf -v B %s x; gh issue edit 1 --body "$B"',
378
+ 'source ./x.sh; gh issue edit 1 --body "$B"',
379
+ 'source ./x.sh; gh issue edit 1 --body-file rel.md',
380
+ '. ./x.sh && gh pr edit 1 --body "$B"',
381
+ `eval 'gh issue edit 1 --body real'`,
382
+ 'echo ${ gh issue edit 1 --body ""; }',
383
+ 'alias g=gh; g issue edit 1 --body ""',
384
+ 'f() { gh issue edit 1 --body real; }; f',
385
+ `gh issue ed\\\nit 1 --body real`,
386
+ // Through the merged runner the secret-guard speaks first on some of these (its own gate); still a block.
387
+ ]) ok(`blocks an unmodelled construct: ${c.slice(0, 50)}`, code(c) === CODE || (!!process.env.TRAILHEAD_GUARD_HOOK && code(c) !== null));
388
+
389
+ // An unknowable body behind a quoted key blocks too (through the merged runner the secret-guard may speak first).
390
+ ok('a quoted api key with an unknowable body blocks', code('gh api -X PATCH repos/o/r/issues/1 -f b\\ody="$B"') !== null);
391
+ ok('a quoted api key with an unreadable file blocks', code("gh api -X PATCH repos/o/r/issues/1 -f 'body'=@/nonexistent/bg-um.md") !== null);
392
+ for (const c of [
393
+ `printf -v B %s ''; gh issue edit 1 --body "it's $B and 'x'"`,
394
+ `source x.sh; gh issue edit 1 --body "it's $B and 'x'"`,
395
+ `printf -v B %s ''; printf %s "$B" | gh issue edit 1 --body-file -`,
396
+ `printf -v B %s ''; gh issue edit 1 --body-file - <<EOF\n$B\nEOF`,
397
+ ]) ok(`W2: a state-dependent write after the construct still blocks: ${c.slice(0, 40).replace(/\n/g, ' ')}`, code(c) !== null);
398
+ for (const c of ["source x; gh issue edit 1 --body 'lit'", 'source x; gh issue edit 1 --body lit', `source x; gh issue edit 1 --body "it's fine"`]) {
399
+ ok(`W2: a literal write after source passes: ${c}`, runHook(c).code === 0);
400
+ }
401
+ ok('the existing empty-body verdict keeps priority', code('source ./x.sh; gh issue edit 1 --body ""') === 'EMPTY_ISSUE_BODY_WRITE' ||
402
+ (!!process.env.TRAILHEAD_GUARD_HOOK && code('source ./x.sh; gh issue edit 1 --body ""') !== null));
403
+ ok('an empty body before the construct keeps its verdict', code('gh issue edit 1 --body ""; alias g=gh') === 'EMPTY_ISSUE_BODY_WRITE');
404
+
405
+ // Plain controls pass.
406
+ for (const c of [
407
+ "alias ll='ls -l'",
408
+ 'alias g=gh',
409
+ 'source ~/.venv/bin/activate && pytest',
410
+ 'eval "$(ssh-agent)"',
411
+ 'eval "$(ssh-agent)"; gh issue edit 1 --body-file ' + clean,
412
+ 'printf -v x %s y; echo $x',
413
+ `gh issue edit 1 --body-file ${clean}; source ./x.sh`,
414
+ `. venv/bin/activate && gh issue edit 1 --body 'hello'`,
415
+ `source venv/bin/activate && gh issue edit 1 --body-file ${clean}`,
416
+ 'f() { gh issue edit 1 --body x; }',
417
+ `gh issue edit 1 --body 'use eval, source and alias here'`,
418
+ `gh issue edit 1 --body-file ${clean} # eval x`,
419
+ `gh issue edit 1 \\\n --body-file ${clean}`,
420
+ 'gh api -X PATCH repos/o/r/issues/1 -f body=x',
421
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=real`, // a known, non-empty body is read like the plain form
422
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=@${clean}`,
423
+ 'gh api -X PATCH repos/o/r/issues/1 -f "body=x"',
424
+ `gh api -X PATCH repos/o/r/issues/1 -F title=x`,
425
+ ]) ok(`passes a plain form: ${c.slice(0, 50)}`, runHook(c).code === 0);
426
+
427
+ // Not a body-guard concern (a comment adds text, it overwrites nothing); the merged runner's
428
+ // secret-guard may block these for its own reasons, so only the standalone guard is checked.
429
+ if (!process.env.TRAILHEAD_GUARD_HOOK) {
430
+ for (const c of [
431
+ 'alias g=gh; g issue comment 1 --body x',
432
+ 'printf -v B %s x; gh issue comment 1 --body "$B"',
433
+ `source ./x.sh; gh issue comment 1 --body-file ${clean}`,
434
+ ]) ok(`passes a non-overwriting write: ${c.slice(0, 50)}`, runHook(c).code === 0);
435
+ }
436
+
437
+ const r = runHook(`gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`);
438
+ const reason = JSON.parse(r.out).reason;
439
+ ok('the message names the guard and asks for the simple form', /^trailhead body-guard: /.test(reason) && /Rewrite it in a simple form/.test(reason));
440
+ fs.rmSync(clean);
441
+ }
442
+
222
443
  console.log(`✓ body-guard: ${passed} assertions passed`);
@@ -5,15 +5,17 @@
5
5
  // 2. Conventional Commits subject: <type>(<scope>)?: <subject>, <=72 chars.
6
6
  // Blocks with exit 2 + {"decision":"block","reason":...}; allows everything else.
7
7
  // Crash-safe: any error → exit 0 (never wedge the user's workflow).
8
- // Self-contained, no dependencies.
8
+ // Self-contained, no dependencies. Exports a pure evaluate(cmd) so the merged
9
+ // trailhead-bash-guards.js can run it in-process; run directly it is unchanged.
9
10
 
10
11
  const { checkCommitMessage } = require('./lib/commit-message-check.js');
11
12
 
12
13
  const CO_AUTHORED = /co-authored-by\s*:/i;
13
14
 
14
- function block(code, reason) {
15
- process.stdout.write(JSON.stringify({ decision: 'block', code, reason }));
16
- process.exit(2);
15
+ const ALLOW = { kind: 'allow' };
16
+
17
+ function blockVerdict(code, reason) {
18
+ return { kind: 'block', payload: { decision: 'block', code, reason } };
17
19
  }
18
20
 
19
21
  // Token-walk: is this shell string a `git commit …`? Handles env-var prefixes
@@ -50,18 +52,15 @@ function extractMessage(cmd) {
50
52
  return null;
51
53
  }
52
54
 
53
- let data = '';
54
- const timer = setTimeout(() => process.exit(0), 5000);
55
- process.stdin.on('data', (c) => (data += c));
56
- process.stdin.on('end', () => {
57
- clearTimeout(timer);
55
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
56
+ // exits or writes; any internal error falls through to allow (crash-safe).
57
+ function evaluate(cmd) {
58
58
  try {
59
- const cmd = (JSON.parse(data).tool_input || {}).command || '';
60
- if (!isGitCommit(cmd)) process.exit(0);
59
+ if (!isGitCommit(cmd)) return ALLOW;
61
60
 
62
61
  // Co-Authored-By anywhere in the command (covers -m, -F, heredocs).
63
62
  if (CO_AUTHORED.test(cmd)) {
64
- block('CO_AUTHORED_BY_FORBIDDEN',
63
+ return blockVerdict('CO_AUTHORED_BY_FORBIDDEN',
65
64
  'trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.');
66
65
  }
67
66
 
@@ -71,10 +70,38 @@ process.stdin.on('end', () => {
71
70
  // `#` lines as message text (cleanup=whitespace), so a `#…` first line is
72
71
  // a real subject to validate, not a comment to skip.
73
72
  const verdict = checkCommitMessage(msg, { stripComments: false });
74
- if (!verdict.ok) block(verdict.code, verdict.reason);
73
+ if (!verdict.ok) return blockVerdict(verdict.code, verdict.reason);
75
74
  }
76
75
  } catch {
77
76
  // fall through to allow
78
77
  }
78
+ return ALLOW;
79
+ }
80
+
81
+ // Standalone CLI: write the payload and exit exactly as the guard always did.
82
+ function run(data) {
83
+ let cmd = '';
84
+ try {
85
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
86
+ } catch {
87
+ process.exit(0);
88
+ }
89
+ const v = evaluate(cmd);
90
+ if (v.kind === 'block') {
91
+ process.stdout.write(JSON.stringify(v.payload));
92
+ process.exit(2);
93
+ }
79
94
  process.exit(0);
80
- });
95
+ }
96
+
97
+ if (require.main === module) {
98
+ let data = '';
99
+ const timer = setTimeout(() => process.exit(0), 5000);
100
+ process.stdin.on('data', (c) => (data += c));
101
+ process.stdin.on('end', () => {
102
+ clearTimeout(timer);
103
+ run(data);
104
+ });
105
+ }
106
+
107
+ module.exports = { evaluate };
@@ -5,7 +5,8 @@ const assert = require('assert');
5
5
  const { execFileSync } = require('child_process');
6
6
  const path = require('path');
7
7
 
8
- const HOOK = path.join(__dirname, 'trailhead-commit-guard.js');
8
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
9
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-commit-guard.js');
9
10
 
10
11
  let passed = 0;
11
12
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -13,8 +13,8 @@
13
13
  // and local paths / URLs / VCS refs carry no typed name and pass through.
14
14
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
15
15
  // Crash-safe: any error → exit 0 (never wedge the user's workflow).
16
- // Self-contained, no dependencies. Pure functions exported for tests; runs as a
17
- // hook when executed directly.
16
+ // Self-contained, no dependencies. Pure functions (incl. evaluate) exported for tests
17
+ // and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
18
18
 
19
19
  const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
20
20
 
@@ -133,7 +133,7 @@ function isVerified(cmd) {
133
133
  return SENTINEL.test(String(cmd));
134
134
  }
135
135
 
136
- function block(hit) {
136
+ function blockVerdict(hit) {
137
137
  const list = hit.packages.join(', ');
138
138
  const reason =
139
139
  `trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
@@ -142,8 +142,20 @@ function block(hit) {
142
142
  'and repository metadata (present and matching). ' +
143
143
  'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
144
144
  'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
145
- process.stdout.write(JSON.stringify({ decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason }));
146
- process.exit(2);
145
+ return { kind: 'block', payload: { decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason } };
146
+ }
147
+
148
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
149
+ // exits or writes; any internal error falls through to allow.
150
+ function evaluate(cmd) {
151
+ try {
152
+ if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
153
+ const hit = detectInstall(cmd);
154
+ if (hit) return blockVerdict(hit);
155
+ } catch {
156
+ // fall through to allow: a guard must never wedge the workflow
157
+ }
158
+ return { kind: 'allow' };
147
159
  }
148
160
 
149
161
  function run(data) {
@@ -153,12 +165,10 @@ function run(data) {
153
165
  } catch {
154
166
  process.exit(0); // unparseable input: nothing to gate
155
167
  }
156
- try {
157
- if (isVerified(cmd)) process.exit(0); // agent has vetted these
158
- const hit = detectInstall(cmd);
159
- if (hit) block(hit);
160
- } catch {
161
- // fall through to allow: a guard must never wedge the workflow
168
+ const v = evaluate(cmd);
169
+ if (v.kind === 'block') {
170
+ process.stdout.write(JSON.stringify(v.payload));
171
+ process.exit(2);
162
172
  }
163
173
  process.exit(0);
164
174
  }
@@ -173,4 +183,4 @@ if (require.main === module) {
173
183
  });
174
184
  }
175
185
 
176
- module.exports = { detectInstall, isVerified };
186
+ module.exports = { detectInstall, isVerified, evaluate };
@@ -5,7 +5,8 @@ const assert = require('assert');
5
5
  const { execFileSync } = require('child_process');
6
6
  const path = require('path');
7
7
 
8
- const HOOK = path.join(__dirname, 'trailhead-install-guard.js');
8
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
9
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
9
10
  const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
10
11
 
11
12
  let passed = 0;
@@ -7,7 +7,14 @@
7
7
  // practice (an approval asked with only the project's name, no URL), so this
8
8
  // hook blocks an AskUserQuestion that is a mockup-approval ask when the
9
9
  // assistant prose immediately before it (no tool call in between) holds no
10
- // URL or file path.
10
+ // URL or file path. A link carried by the approval question itself (question,
11
+ // header, option label or description) is accepted too.
12
+ // Transcript timing: Claude Code writes the CURRENT assistant message (its
13
+ // text and tool_use blocks) to the transcript only after the tool call
14
+ // completes, so at PreToolUse the prose carrying the link is usually not
15
+ // there yet. The prose check therefore runs only when the pending ask is
16
+ // found in the transcript (by tool_use_id, or as the identical tail ask);
17
+ // otherwise the guard cannot judge and allows.
11
18
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
12
19
  // Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
13
20
  // unexpected shape must never wedge the workflow.
@@ -16,7 +23,7 @@
16
23
  // Pure functions are exported for tests; runs as a hook when executed directly.
17
24
 
18
25
  const fs = require('fs');
19
- const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
26
+ const { hasLink, isMockupApprovalAsk, askHasLink, BLOCK_REASON } = require('./lib/mockup-link.js');
20
27
 
21
28
  // Only the transcript tail matters: the prose sits right before the ask.
22
29
  const TAIL_BYTES = 512 * 1024;
@@ -25,14 +32,41 @@ const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.messa
25
32
  const isPendingAsk = (e) => e.type === 'assistant'
26
33
  && blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
27
34
 
35
+ const mainChain = (entries) => entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
36
+
37
+ // Index (in the main chain) of the entry holding the pending ask being
38
+ // judged, or -1 when it is not in the transcript yet. With a tool_use id:
39
+ // the assistant entry carrying that id. Without: the last entry, only if it
40
+ // is a pending AskUserQuestion whose input equals toolInput.
41
+ function pendingAskIndex(entries, toolUseId, toolInput) {
42
+ const main = mainChain(entries);
43
+ if (toolUseId) {
44
+ return main.findIndex((e) => e.type === 'assistant'
45
+ && blocksOf(e).some((b) => b.type === 'tool_use' && b.id === toolUseId));
46
+ }
47
+ const i = main.length - 1;
48
+ if (i < 0 || !isPendingAsk(main[i])) return -1;
49
+ const same = blocksOf(main[i]).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion'
50
+ && JSON.stringify(b.input) === JSON.stringify(toolInput));
51
+ return same ? i : -1;
52
+ }
53
+
28
54
  // 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--;
55
+ // from the anchor entry (default: the end), skip the pending AskUserQuestion
56
+ // entries (keeping the text that precedes the tool_use inside the anchor
57
+ // entry itself), and collect text blocks until anything else (a user turn, a tool
58
+ // result, another tool call).
59
+ function proseBeforeAsk(entries, anchor) {
60
+ const main = mainChain(entries);
61
+ let i = anchor === undefined ? main.length - 1 : anchor;
35
62
  const texts = [];
63
+ // Text blocks in the same entry, before its tool_use, are prose too.
64
+ if (i >= 0 && main[i] && isPendingAsk(main[i])) {
65
+ const blocks = blocksOf(main[i]);
66
+ const firstTool = blocks.findIndex((b) => b.type === 'tool_use');
67
+ blocks.slice(0, firstTool).filter((b) => b.type === 'text').forEach((b) => texts.push(b.text || ''));
68
+ }
69
+ while (i >= 0 && isPendingAsk(main[i])) i--;
36
70
  for (; i >= 0; i--) {
37
71
  const e = main[i];
38
72
  if (e.type !== 'assistant') break;
@@ -73,9 +107,12 @@ function run(data) {
73
107
  const payload = JSON.parse(data);
74
108
  if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
75
109
  if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
110
+ if (askHasLink(payload.tool_input)) process.exit(0);
76
111
  if (!payload.transcript_path) process.exit(0);
77
- const prose = proseBeforeAsk(parseEntries(readTail(payload.transcript_path)));
78
- if (!hasLink(prose)) block();
112
+ const entries = parseEntries(readTail(payload.transcript_path));
113
+ const idx = pendingAskIndex(entries, payload.tool_use_id, payload.tool_input);
114
+ if (idx < 0) process.exit(0); // current message not flushed yet: cannot judge
115
+ if (!hasLink(proseBeforeAsk(entries, idx))) block();
79
116
  } catch {
80
117
  // fall through to allow: a guard must never wedge the workflow
81
118
  }
@@ -92,4 +129,4 @@ if (require.main === module) {
92
129
  });
93
130
  }
94
131
 
95
- module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
132
+ module.exports = { isMockupApprovalAsk, hasLink, askHasLink, proseBeforeAsk, pendingAskIndex };
@@ -10,6 +10,8 @@ const {
10
10
  isMockupApprovalAsk,
11
11
  hasLink,
12
12
  proseBeforeAsk,
13
+ pendingAskIndex,
14
+ askHasLink,
13
15
  } = require('./trailhead-mockup-link-guard.js');
14
16
 
15
17
  const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
@@ -20,7 +22,7 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
22
  // Costruttori di righe transcript (stesso shape JSONL di Claude Code)
21
23
  const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
22
24
  const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
23
- const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id: 'x', name, input }] } });
25
+ const toolUse = (name, input = {}, id = 'x') => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id, name, input }] } });
24
26
  const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
25
27
  const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
26
28
 
@@ -103,8 +105,45 @@ const sidechain = [
103
105
  ];
104
106
  ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
105
107
 
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
+ // Senza ancora: comportamento storico, si parte dalla coda del transcript
109
+ const noAnchor = [userPrompt('x'), text('Ecco https://a.b/c')];
110
+ ok('without an anchor the prose is read from the transcript tail', hasLink(proseBeforeAsk(noAnchor)));
111
+
112
+ // Con ancora: si parte dall'ask trovato, non dalla coda
113
+ const anchored = [
114
+ userPrompt('x'),
115
+ text('Mockup: https://a.b/c'),
116
+ toolUse('AskUserQuestion', approvalInput, 'ask1'),
117
+ toolResult('Approvo'),
118
+ text('Altro testo senza link'),
119
+ ];
120
+ ok('with an anchor the prose is read back from the anchored ask', hasLink(proseBeforeAsk(anchored, 2)));
121
+
122
+ // --- askHasLink ---
123
+ const askWith = (over) => ({ questions: [{ ...approvalInput.questions[0], ...over }] });
124
+ ok('link in the approval question counts', askHasLink(askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' })));
125
+ ok('link in the approval header counts', askHasLink(askWith({ header: 'https://claude.ai/artifact/AbC123' })));
126
+ ok('link in an approval option label counts', askHasLink(askWith({ options: [{ label: 'Apri https://a.b/c' }, { label: 'No' }] })));
127
+ ok('link in an approval option description counts', askHasLink(askWith({ options: [{ label: 'Approvo', description: 'vedi mockups/faq.html' }] })));
128
+ ok('approval ask without any link has none', !askHasLink(approvalInput));
129
+ ok('link only in a different non-approval question does not count', !askHasLink({
130
+ questions: [
131
+ { question: 'Dove sta il piano?', options: [{ label: 'https://a.b/piano' }] },
132
+ approvalInput.questions[0],
133
+ ],
134
+ }));
135
+ ok('malformed input has no ask link', !askHasLink({}) && !askHasLink(null));
136
+
137
+ // --- pendingAskIndex ---
138
+ const withIds = [userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok'), text('Prosa'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
139
+ ok('pendingAskIndex finds the entry by tool_use id', pendingAskIndex(withIds, 'ask1', approvalInput) === 4);
140
+ ok('pendingAskIndex by id ignores a missing id', pendingAskIndex(withIds, 'nope', approvalInput) === -1);
141
+ ok('pendingAskIndex without id falls back to input equality on the tail', pendingAskIndex(withIds, undefined, approvalInput) === 4);
142
+ ok('pendingAskIndex without id is -1 when the tail ask has a different input',
143
+ pendingAskIndex(withIds, undefined, askWith({ question: 'Approvi il mockup B?' })) === -1);
144
+ ok('pendingAskIndex is -1 when the tail is the previous tool_result',
145
+ pendingAskIndex([userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok')], undefined, approvalInput) === -1);
146
+ ok('pendingAskIndex ignores sidechain entries', pendingAskIndex([{ ...toolUse('AskUserQuestion', approvalInput, 'ask1'), isSidechain: true }], 'ask1', approvalInput) === -1);
108
147
 
109
148
  // --- end-to-end ---
110
149
  function writeTranscript(entries) {
@@ -138,6 +177,39 @@ ok('other tools are ignored', r4.code === 0);
138
177
  const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
139
178
  ok('non-mockup ask is allowed', r5.code === 0);
140
179
 
180
+ // Shape reale di Claude Code 2.1.295: il messaggio corrente non e' ancora nel transcript
181
+ const unflushed = [userPrompt('lavora #12'), toolUse('Bash', {}, 'b1'), toolResult('ok')];
182
+ const ra = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(unflushed) });
183
+ ok('(a) transcript ending at a previous tool_result fails open', ra.code === 0 && ra.out.trim() === '');
184
+
185
+ const flushedNoLink = [...failing.slice(0, 5), toolUse('AskUserQuestion', approvalInput, 'ask1')];
186
+ const rb = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
187
+ ok('(b) tool_use_id with the ask entry present and no link in the prose is blocked', rb.code === 2 && /MOCKUP_LINK_MISSING/.test(rb.out));
188
+
189
+ const linkedAsk = askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' });
190
+ const rc = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: linkedAsk, transcript_path: writeTranscript(failing) });
191
+ ok('(c) link carried by the ask question is allowed', rc.code === 0 && rc.out.trim() === '');
192
+
193
+ const otherPending = [userPrompt('x'), text('Niente link'), toolUse('AskUserQuestion', { questions: [{ question: 'Altro?', options: [] }] }, 'other')];
194
+ const rd = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(otherPending) });
195
+ ok('(d) transcript ending in a different pending ask fails open', rd.code === 0 && rd.out.trim() === '');
196
+
197
+ // Testo e tool_use nella STESSA riga di transcript
198
+ const sameEntry = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }, { type: 'tool_use', id: 'ask1', name: 'AskUserQuestion', input: approvalInput }] } });
199
+ ok('text before the tool_use in the same entry counts as prose', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')], 1)));
200
+ ok('same-entry text is also read from the tail', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')])));
201
+ const rs1 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Vedi https://a.b/c')]) });
202
+ ok('same-entry text with a link is allowed', rs1.code === 0 && rs1.out.trim() === '');
203
+ const rs2 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Mockup pronto su MyApp')]) });
204
+ ok('same-entry text without a link is blocked', rs2.code === 2 && /MOCKUP_LINK_MISSING/.test(rs2.out));
205
+
206
+ // Senza tool_use_id: ask in coda identico a tool_input (fallback per uguaglianza)
207
+ const ri1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
208
+ ok('no id, identical tail ask, no link in the prose is blocked', ri1.code === 2 && /MOCKUP_LINK_MISSING/.test(ri1.out));
209
+ const flushedLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/artifact/AbC123xyz'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
210
+ const ri2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedLink) });
211
+ ok('no id, identical tail ask, link in the prose is allowed', ri2.code === 0 && ri2.out.trim() === '');
212
+
141
213
  let r6;
142
214
  try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
143
215
  ok('unparseable input fails open', r6 === 0);