@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -10,6 +10,8 @@ const {
10
10
  isMockupApprovalAsk,
11
11
  hasLink,
12
12
  proseBeforeAsk,
13
+ pendingAskIndex,
14
+ askHasLink,
13
15
  } = require('./trailhead-mockup-link-guard.js');
14
16
 
15
17
  const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
@@ -20,7 +22,7 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
22
  // Costruttori di righe transcript (stesso shape JSONL di Claude Code)
21
23
  const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
22
24
  const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
23
- const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id: '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);
@@ -24,6 +24,21 @@
24
24
  // cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
25
25
  // common shape) so it is deliberately left to the prose directive.
26
26
  //
27
+ // Redirections are not reads of a relative path: before a verb's args are
28
+ // walked, output redirections (`>`, `>>`, `2>`, `&>`, glued or with a separate
29
+ // target), fd duplications (`2>&1`) and heredoc/herestring introducers are
30
+ // dropped together with their target, so `cat /abs/a > out.txt` or
31
+ // `tail /abs/log 2>/dev/null` are not reported. An input redirection `< f` IS a
32
+ // read: only the operator is dropped and `f` stays in the arg list, so
33
+ // `cd app && cat < rel.txt` still blocks while `wc -l < /abs/a` passes.
34
+ //
35
+ // cwd exemption: when the hook payload carries the session `cwd` (absolute), a
36
+ // joined `cd X`/`pushd X` whose literal target resolves to exactly that cwd is
37
+ // a no-op, so it does not make later relative reads non-static. Dynamic or
38
+ // special targets (`$VAR`, backticks, `~`, `-`, a bare `cd`) always count as a
39
+ // directory change, and an unknown cwd behaves as before. The exemption only
40
+ // avoids ARMING: once a real cd has armed the guard, nothing disarms it.
41
+ //
27
42
  // Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
28
43
  // everything else. Crash-safe: any error -> exit 0 (never wedge the user's
29
44
  // workflow). Self-contained (its own dependency is the sibling lib/ module,
@@ -36,12 +51,14 @@
36
51
  // machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
37
52
  // trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
38
53
  // the two guards can never drift into two divergent tokenizers.
54
+ const path = require('path');
39
55
  const {
40
56
  basename,
41
57
  stripQuotes,
42
58
  splitStatements,
43
59
  tokenize,
44
60
  skipEnvPrefix,
61
+ stripRedirections,
45
62
  SEARCH_VERB_CONFIG,
46
63
  scanArgsSkippingPattern,
47
64
  } = require('./lib/shell-scan.js');
@@ -114,7 +131,7 @@ const VERB_CONFIG = {
114
131
  // Walk a verb's argument tokens (verb itself already stripped) for the first
115
132
  // relative FILE argument found, via the shared pattern/flag-skipping walker.
116
133
  function scanArgsForRelativeFile(args, config) {
117
- return scanArgsSkippingPattern(args, config, isRelativeFileArg);
134
+ return scanArgsSkippingPattern(stripRedirections(args), config, isRelativeFileArg);
118
135
  }
119
136
 
120
137
  // `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
@@ -177,6 +194,22 @@ function isCdOrPushdStatement(stmt) {
177
194
  return bin === 'cd' || bin === 'pushd';
178
195
  }
179
196
 
197
+ // Does this cd/pushd statement provably stay in the session cwd? Only with a
198
+ // known absolute cwd and exactly one static literal target resolving to it.
199
+ function cdTargetsSessionCwd(stmt, cwd) {
200
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
201
+ const tokens = normalizeStatementTokens(tokenize(stmt));
202
+ let i = skipEnvPrefix(tokens) + 1; // past cd/pushd
203
+ while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') {
204
+ if (tokens[i++] === '--') break; // option flags (-P/-L/--) before the target
205
+ }
206
+ if (i !== tokens.length - 1) return false; // bare cd goes to $HOME; extra targets are unsure
207
+ const target = stripQuotes(tokens[i]);
208
+ if (!target || target === '-' || target.startsWith('~')) return false;
209
+ if (target.includes('$') || target.includes('`')) return false;
210
+ return path.resolve(cwd, target) === path.resolve(cwd);
211
+ }
212
+
180
213
  // `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
181
214
  // statement (env execs its argument as a single command with that cwd), so
182
215
  // this is checked independently of the cross-statement `cwdChanged` flag.
@@ -215,7 +248,7 @@ function envDashCRelativeRead(stmt) {
215
248
  // with no path argument at all implicitly searches the (now-relative) cwd,
216
249
  // but adding that case risks false-positiving on legitimate stdin-reading
217
250
  // commands, so it is left to the prose directive.
218
- function detectSearchHygieneViolation(cmd) {
251
+ function detectSearchHygieneViolation(cmd, { cwd } = {}) {
219
252
  const statements = splitStatements(cmd);
220
253
  let cwdChanged = false;
221
254
  for (let idx = 0; idx < statements.length; idx++) {
@@ -233,35 +266,48 @@ function detectSearchHygieneViolation(cmd) {
233
266
  // the sole statement) - a lone standalone `cd` is the known limitation
234
267
  // above and must never be blocked here.
235
268
  if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
236
- cwdChanged = true;
269
+ if (!cdTargetsSessionCwd(stmt, cwd)) cwdChanged = true;
237
270
  }
238
271
  }
239
272
  return null;
240
273
  }
241
274
 
242
- function block(hit) {
275
+ function blockVerdict(hit) {
243
276
  const reason =
244
277
  "trailhead search-hygiene: a 'cd <dir>' (or pushd/env -C) followed by a relative " +
245
278
  `${hit.verb} read (\`${hit.file}\`) makes the read target non-static, which trips the ` +
246
279
  'Read() deny rule under bypass permissions (prompting for manual approval instead of ' +
247
280
  'running headless). Pass explicit paths instead and never cd first: ' +
248
281
  'grep -niE "<patterns>" <abs>/<file>, git -C <abs> ..., or use the Grep/Read tools.';
249
- process.stdout.write(JSON.stringify({ decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason }));
250
- process.exit(2);
282
+ return { kind: 'block', payload: { decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason } };
283
+ }
284
+
285
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
286
+ // exits or writes; any internal error falls through to allow.
287
+ function evaluate(cmd, { cwd } = {}) {
288
+ try {
289
+ const hit = detectSearchHygieneViolation(cmd, { cwd });
290
+ if (hit) return blockVerdict(hit);
291
+ } catch {
292
+ // fall through to allow: a guard must never wedge the workflow
293
+ }
294
+ return { kind: 'allow' };
251
295
  }
252
296
 
253
297
  function run(data) {
254
298
  let cmd = '';
299
+ let cwd;
255
300
  try {
256
- cmd = (JSON.parse(data).tool_input || {}).command || '';
301
+ const input = JSON.parse(data);
302
+ cmd = (input.tool_input || {}).command || '';
303
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
257
304
  } catch {
258
305
  process.exit(0); // unparseable input: nothing to gate
259
306
  }
260
- try {
261
- const hit = detectSearchHygieneViolation(cmd);
262
- if (hit) block(hit);
263
- } catch {
264
- // fall through to allow: a guard must never wedge the workflow
307
+ const v = evaluate(cmd, { cwd });
308
+ if (v.kind === 'block') {
309
+ process.stdout.write(JSON.stringify(v.payload));
310
+ process.exit(2);
265
311
  }
266
312
  process.exit(0);
267
313
  }
@@ -276,4 +322,4 @@ if (require.main === module) {
276
322
  });
277
323
  }
278
324
 
279
- module.exports = { detectSearchHygieneViolation };
325
+ module.exports = { detectSearchHygieneViolation, evaluate };
@@ -5,14 +5,17 @@ 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-search-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-search-guard.js');
9
10
 
10
11
  let passed = 0;
11
12
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
12
13
 
13
14
  // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
14
- function runHook(command) {
15
- const input = JSON.stringify({ tool_input: { command } });
15
+ function runHook(command, cwd) {
16
+ const payload = { tool_input: { command } };
17
+ if (cwd !== undefined) payload.cwd = cwd; // optional top-level session cwd
18
+ const input = JSON.stringify(payload);
16
19
  try {
17
20
  const out = execFileSync('node', [HOOK], { input });
18
21
  return { code: 0, out: out.toString() };
@@ -113,4 +116,66 @@ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the f
113
116
  const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
114
117
  ok('cd + rg -g <glob> <pattern> <relative file> reports the real file, not the pattern (exit 2)', f1.code === 2 && /search-hygiene/.test(f1.out) && /src\/foo\.js/.test(f1.out) && !/`TODO`/.test(f1.out));
115
118
 
119
+
120
+ // --- #238: redirections are not relative reads; a cd to the session cwd is static ---
121
+ const blocked = (r) => r.code === 2 && /search-hygiene/.test(r.out);
122
+ const allowed = (r) => r.code === 0 && r.out.trim() === '';
123
+
124
+ for (const [name, cmd] of [
125
+ ['heredoc-fed cat > abs', 'cd /x && cat > /abs/file <<EOF\nhi\nEOF'],
126
+ ['append redirect target', 'cd /x && cat /abs/a >> out.txt'],
127
+ ['output redirect target', 'cd /x && cat /abs/a > out.txt'],
128
+ ['glued 2>/dev/null', 'cd /x && tail -5 /abs/log 2>/dev/null'],
129
+ ['separate 2> target', 'cd /x && tail -5 /abs/log 2> err.log'],
130
+ ['&> target', 'cd /x && cat /abs/a &> out.txt'],
131
+ ['2>&1 duplication', 'cd /x && cat /abs/a 2>&1'],
132
+ ['absolute input redirect', 'cd /x && wc -l < /abs/a'],
133
+ ['grep with 2>/dev/null', 'cd /x && grep -n foo /abs/a 2>/dev/null'],
134
+ ['herestring', 'cd /x && cat <<<hello'],
135
+ ]) ok(`#238 allow: ${name}`, allowed(runHook(cmd)));
136
+
137
+ for (const [name, cmd, file] of [
138
+ ['input redirect from relative file', 'cd app && cat < rel.txt', 'rel.txt'],
139
+ ['relative read before output redirect', 'cd app && cat rel.txt > /abs/out', 'rel.txt'],
140
+ ['relative read before 2>/dev/null', 'cd app && tail -5 rel.log 2>/dev/null', 'rel.log'],
141
+ ['relative read before 2>&1', 'cd app && grep -n x rel.txt 2>&1', 'rel.txt'],
142
+ ]) {
143
+ const r = runHook(cmd);
144
+ ok(`#238 block: ${name}`, blocked(r) && r.out.includes('`' + file + '`'));
145
+ }
146
+
147
+ const CWD = '/home/u/p';
148
+ for (const [name, cmd] of [
149
+ ['cd to session cwd', 'cd /home/u/p && cat rel.txt'],
150
+ ['cd to session cwd with trailing slash', 'cd /home/u/p/ && grep -n x rel.txt'],
151
+ ['cd .', 'cd . && cat rel.txt'],
152
+ ]) ok(`#238 allow with cwd: ${name}`, allowed(runHook(cmd, CWD)));
153
+
154
+ for (const [name, cmd] of [
155
+ ['cd elsewhere', 'cd /home/u/other && cat rel.txt'],
156
+ ['cd relative subdir', 'cd sub && cat rel.txt'],
157
+ ['cd $HOME', 'cd $HOME && cat rel.txt'],
158
+ ['cd ~', 'cd ~ && cat rel.txt'],
159
+ ['cd -', 'cd - && cat rel.txt'],
160
+ ]) ok(`#238 block with cwd: ${name}`, blocked(runHook(cmd, CWD)));
161
+ ok('#238 block: cd to would-be cwd without payload cwd', blocked(runHook('cd /home/u/p && cat rel.txt')));
162
+
163
+ // pure unit checks on the exported detector
164
+ const { detectSearchHygieneViolation } = require('./trailhead-search-guard.js');
165
+ ok('#238 unit: cd <cwd> is static when cwd is known',
166
+ detectSearchHygieneViolation('cd "/home/u/p" && cat rel.txt', { cwd: CWD }) === null);
167
+ ok('#238 unit: cd <cwd> still violates when cwd unknown',
168
+ detectSearchHygieneViolation('cd /home/u/p && cat rel.txt').file === 'rel.txt');
169
+ ok('#238 unit: relative cd after a real cd stays armed',
170
+ detectSearchHygieneViolation('cd sub && cd . && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
171
+ // once armed by a real cd, nothing disarms: a cd to the session cwd only avoids arming
172
+ for (const cmd of [
173
+ 'cd sub || cd /home/u/p && cat rel.txt',
174
+ 'cd sub && (cd /home/u/p) && cat rel.txt',
175
+ 'cd sub && cd /home/u/p && cat rel.txt',
176
+ ]) ok(`#238 unit: stays armed: ${cmd}`, detectSearchHygieneViolation(cmd, { cwd: CWD }).file === 'rel.txt');
177
+ ok('#238 unit: cd with an extra target is not a no-op',
178
+ detectSearchHygieneViolation('cd /home/u/p /other && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
179
+ ok('#238 allow: separate-token >& target', allowed(runHook('cd app && cat /abs/a >& out.txt')));
180
+
116
181
  console.log(`✓ search-guard: ${passed} assertions passed`);