@@ -315,33 +315,56 @@ function workflowDeclaresDryRunInput(
315315// intentionally excluded so a same-named workflow in the current
316316// checkout can't false-positive a cross-repo dispatch.
317317function resolveSearchRoots ( command : string ) : string [ ] {
318- // Resolution order: $CLAUDE_PROJECT_DIR (Claude Code sets this when
319- // it remembers to) → derive from this hook script's path (the hook
320- // lives at <project>/.claude/hooks/release-workflow-guard/index.mts,
321- // so go three levels up from __dirname) → $PWD as last resort.
322- // The script-path derivation is the most robust because it doesn't
323- // depend on the runner exporting env vars correctly.
324- let projectDir = process . env [ 'CLAUDE_PROJECT_DIR' ]
325- if ( ! projectDir ) {
326- // process.argv[1] is the absolute path of this hook script when
327- // invoked via `node <path>`. Walk up to the repo root.
328- const scriptPath = process . argv [ 1 ]
329- if ( scriptPath ) {
330- // .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo
331- const candidate = path . resolve ( scriptPath , '..' , '..' , '..' , '..' )
332- if ( existsSync ( path . join ( candidate , '.github' , 'workflows' ) ) ) {
333- projectDir = candidate
334- }
318+ // Resolution: collect every plausible project root (env + script
319+ // derivation + cwd), dedupe, and return the list. Downstream
320+ // consumers (workflowDeclaresDryRunInput / classifyWorkflow) iterate
321+ // and fall through, so multiple candidates is strictly safer than
322+ // picking a single one — the env var can point at the *parent* of
323+ // the actual checkout when Claude Code resolves a different repo
324+ // than the one whose hook is running.
325+ const candidates : string [ ] = [ ]
326+ const envDir = process . env [ 'CLAUDE_PROJECT_DIR' ]
327+ if ( envDir ) {
328+ candidates . push ( envDir )
329+ }
330+ // process.argv[1] is the absolute path of this hook script when
331+ // invoked via `node <path>`. Walk up to the repo root:
332+ // .claude/hooks/release-workflow-guard/index.mts → ../../../ = repo
333+ const scriptPath = process . argv [ 1 ]
334+ if ( scriptPath ) {
335+ const candidate = path . resolve ( scriptPath , '..' , '..' , '..' , '..' )
336+ if ( existsSync ( path . join ( candidate , '.github' , 'workflows' ) ) ) {
337+ candidates . push ( candidate )
335338 }
336339 }
337- if ( ! projectDir ) {
338- projectDir = process . cwd ( )
340+ candidates . push ( process . cwd ( ) )
341+ // Dedupe while preserving order.
342+ const seen = new Set < string > ( )
343+ const unique : string [ ] = [ ]
344+ for ( const c of candidates ) {
345+ if ( ! seen . has ( c ) ) {
346+ seen . add ( c )
347+ unique . push ( c )
348+ }
339349 }
340350 const repoMatch = GH_REPO_FLAG_RE . exec ( command )
341- if ( ! repoMatch || path . basename ( projectDir ) === repoMatch [ 1 ] ! ) {
342- return [ projectDir ]
351+ if ( ! repoMatch ) {
352+ return unique
353+ }
354+ // Cross-repo dispatch: redirect every candidate to its sibling clone
355+ // of the same name. If none of them have a sibling matching, the
356+ // empty list naturally blocks (workflowDeclaresDryRunInput returns
357+ // false → bypass denied → block-the-default).
358+ const repoName = repoMatch [ 1 ] !
359+ const redirected : string [ ] = [ ]
360+ for ( const c of unique ) {
361+ if ( path . basename ( c ) === repoName ) {
362+ redirected . push ( c )
363+ } else {
364+ redirected . push ( path . join ( path . dirname ( c ) , repoName ) )
365+ }
343366 }
344- return [ path . join ( path . dirname ( projectDir ) , repoMatch [ 1 ] ! ) ]
367+ return redirected
345368}
346369
347370function isVerifiableDryRun (
0 commit comments