diff --git a/.claude/hooks/workflow-gate.py b/.claude/hooks/workflow-gate.py index 312fef30..d1305a36 100755 --- a/.claude/hooks/workflow-gate.py +++ b/.claude/hooks/workflow-gate.py @@ -12,9 +12,13 @@ - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/.codex/hooks/workflow-gate.py b/.codex/hooks/workflow-gate.py index 312fef30..d1305a36 100755 --- a/.codex/hooks/workflow-gate.py +++ b/.codex/hooks/workflow-gate.py @@ -12,9 +12,13 @@ - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/CHANGELOG.md b/CHANGELOG.md index e08f2cbc..189f8aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`workflow-gate.py` orthogonal-file relief now applies to every blocking phase, and out-of-repo paths are always allowed (closes #164).** A prior fix (#174) scoped the RESEARCH-phase block to the current subtask's `affected_files`, allowing Edit/Write to files outside that surface — but only during RESEARCH, and it deliberately kept blocking paths that resolve entirely outside the repository. Both choices reproduced the original friction: a report against `neuro-vlad` hit the identical block during INIT_STATE while editing `~/.claude/CLAUDE.md`, a path outside that repo's tree entirely. `is_orthogonal_to_current_subtask()` now treats out-of-repo paths as unconditionally orthogonal (no subtask's `affected_files` can ever legitimately name a path outside the repo it was declared in), and the orthogonal-relief exception in `main()` fires for any blocking phase, not just RESEARCH. The Bash-write bypass (`cat >`, `tee`, `sed -i`) remains a documented, deliberately deferred limitation. + ## [3.21.0] - 2026-07-02 ### Changed diff --git a/src/mapify_cli/templates/codex/hooks/workflow-gate.py b/src/mapify_cli/templates/codex/hooks/workflow-gate.py index 312fef30..d1305a36 100755 --- a/src/mapify_cli/templates/codex/hooks/workflow-gate.py +++ b/src/mapify_cli/templates/codex/hooks/workflow-gate.py @@ -12,9 +12,13 @@ - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/src/mapify_cli/templates/hooks/workflow-gate.py b/src/mapify_cli/templates/hooks/workflow-gate.py index 312fef30..d1305a36 100755 --- a/src/mapify_cli/templates/hooks/workflow-gate.py +++ b/src/mapify_cli/templates/hooks/workflow-gate.py @@ -12,9 +12,13 @@ - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/src/mapify_cli/templates_src/codex/hooks/workflow-gate.py.jinja b/src/mapify_cli/templates_src/codex/hooks/workflow-gate.py.jinja index 312fef30..d1305a36 100755 --- a/src/mapify_cli/templates_src/codex/hooks/workflow-gate.py.jinja +++ b/src/mapify_cli/templates_src/codex/hooks/workflow-gate.py.jinja @@ -12,9 +12,13 @@ ENFORCEMENT: - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/src/mapify_cli/templates_src/hooks/workflow-gate.py.jinja b/src/mapify_cli/templates_src/hooks/workflow-gate.py.jinja index 312fef30..d1305a36 100755 --- a/src/mapify_cli/templates_src/hooks/workflow-gate.py.jinja +++ b/src/mapify_cli/templates_src/hooks/workflow-gate.py.jinja @@ -12,9 +12,13 @@ ENFORCEMENT: - Edit blocked during all other phases (DECOMPOSE, MONITOR, PREDICTOR, etc.) - Fail-open: missing or unreadable step_state.json → allow - Always allows: .map/ artifacts, non-editing tools - - RESEARCH blocks only the CURRENT subtask's declared affected_files; - docs-only surfaces and files orthogonal to that subtask are allowed so - out-of-band hotfixes don't have to be smuggled through Bash (#164). + - Every blocking phase (RESEARCH, INIT_STATE, DECOMPOSE, ...) blocks only + the CURRENT subtask's declared affected_files; files orthogonal to that + subtask are allowed in ANY phase so out-of-band hotfixes don't have to + be smuggled through Bash (#164). RESEARCH additionally allows docs-only + surfaces. A path that resolves entirely outside the repository is + always orthogonal — no subtask's affected_files can name a path outside + the repo tree — and is allowed regardless of phase (#164). CONSTRAINTS (from step_state.json): - scope_glob: restrict edits to matching file patterns @@ -330,19 +334,24 @@ def _current_subtask_affected_files(branch: str) -> Optional[set[str]]: def is_orthogonal_to_current_subtask(branch: str, file_path: str) -> bool: """Return True iff *file_path* is provably OUTSIDE the current subtask's - affected_files — an orthogonal edit RESEARCH does not protect. - - Conservative by construction: returns False ("keep blocking") whenever - the current subtask's mutation surface cannot be determined, or the - target resolves outside the repo. The RESEARCH block is lifted only on - positive evidence that the file belongs to no part of the current - subtask's declared work. + affected_files — an orthogonal edit the phase gate does not protect. + + A path that resolves entirely outside the repository (PROJECT_DIR) is + unconditionally orthogonal: no subtask's affected_files is ever a path + outside the repo tree it was declared in, so there is no "current + subtask context" for such a path to belong to (#164 — a path like + ``~/.claude/CLAUDE.md`` blocked while a MAP session in a different repo + was mid-INIT_STATE). For in-repo paths, conservative by construction: + returns False ("keep blocking") whenever the current subtask's mutation + surface cannot be determined. The phase block is lifted only on positive + evidence that the file belongs to no part of the current subtask's + declared work. """ - affected = _current_subtask_affected_files(branch) - if not affected: - return False rel = _to_repo_relative(file_path) if rel is None: + return True + affected = _current_subtask_affected_files(branch) + if not affected: return False return rel not in affected @@ -398,7 +407,8 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: "\n" f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" - "an unrelated failing test) are allowed during RESEARCH." + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) if current_phase == "MONITOR": return False, ( @@ -417,7 +427,12 @@ def is_editing_phase(branch: str) -> tuple[bool, Optional[str]]: f"Workflow gate: Edit blocked during phase '{current_phase}' " f"(subtask {subtask}).\n" f"Edit is only allowed during: {', '.join(sorted(EDITING_PHASES))}.\n" - "Call the Actor agent first — it will apply code changes." + "Call the Actor agent first — it will apply code changes.\n" + "\n" + f"Note: this block is scoped to {subtask}'s affected_files. Edits to\n" + "files OUTSIDE that surface (orthogonal hotfixes, repo-root config,\n" + "an unrelated failing test, or any path outside this repo) are\n" + "allowed regardless of phase." ) @@ -529,23 +544,21 @@ def main() -> None: if constraint_error: deny(constraint_error) allow() - # RESEARCH exception #2 (orthogonal hotfix): the RESEARCH gate - # exists to force research-before-code for the CURRENT subtask's - # files. Edits to files OUTSIDE that subtask's declared - # affected_files (a repo-root config, an unrelated failing test, an - # out-of-band hotfix the operator asked for) are not what RESEARCH - # protects — blocking them only pushed those edits into Bash - # heredocs (#164). Allow them when EVERY target is provably - # orthogonal, still subject to scope_glob / constraints so the - # relief cannot silently widen scope. A single in-scope target in - # the batch (mixed edit) falls through to the block. - if ( - research - and target_paths - and all( - is_orthogonal_to_current_subtask(branch, p) - for p in target_paths - ) + # Exception #2 (orthogonal hotfix): every blocking phase exists to + # force process-before-code for the CURRENT subtask's files. + # Edits to files OUTSIDE that subtask's declared affected_files (a + # repo-root config, an unrelated failing test, an out-of-band + # hotfix the operator asked for, or a path outside the repo + # entirely) are not what any blocking phase protects — blocking + # them only pushed those edits into Bash heredocs. Originally + # scoped to RESEARCH only; #164's second report hit the identical + # block during INIT_STATE, so the relief applies to ANY blocking + # phase. Allow when EVERY target is provably orthogonal, still + # subject to scope_glob / constraints so the relief cannot + # silently widen scope. A single in-scope target in the batch + # (mixed edit) falls through to the block. + if target_paths and all( + is_orthogonal_to_current_subtask(branch, p) for p in target_paths ): constraint_error = check_constraints(branch, target_paths) if constraint_error: diff --git a/tests/test_mapify_cli.py b/tests/test_mapify_cli.py index 714a2bbf..127703aa 100644 --- a/tests/test_mapify_cli.py +++ b/tests/test_mapify_cli.py @@ -2058,9 +2058,11 @@ def test_ac20_workflow_gate_blocks_during_restricted(self, codex_project): ), "RESEARCH must NOT be in EDITING_PHASES" assert "ACTOR" in editing_phases, "ACTOR must be in EDITING_PHASES" - # Simulate gate invocation: Edit tool during RESEARCH phase → should block + # Simulate gate invocation: Edit tool during RESEARCH phase → should block. + # Path must be in-repo (relative) — an out-of-repo path is unconditionally + # orthogonal (#164) and would be allowed regardless of phase. payload_block = _json.dumps( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}} + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}} ) branch_dir = codex_project / ".map" / "default" branch_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_workflow_gate.py b/tests/test_workflow_gate.py index 4ab0bc8f..c8851033 100644 --- a/tests/test_workflow_gate.py +++ b/tests/test_workflow_gate.py @@ -181,7 +181,7 @@ def test_allows_non_editing_tools(self, tmp_path: Path) -> None: """Read, Bash, and other non-editing tools should always be allowed.""" for tool_name in ["Read", "Bash", "Grep", "Glob", "Task"]: code, stdout, _ = self.run_hook( - {"tool_name": tool_name, "tool_input": {"file_path": "/test.py"}}, + {"tool_name": tool_name, "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0, f"{tool_name} should be allowed" @@ -192,7 +192,7 @@ def test_allows_non_editing_tools(self, tmp_path: Path) -> None: def test_allows_edit_when_no_state_files(self, tmp_path: Path) -> None: """Edit allowed when no step_state.json exists.""" code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -205,7 +205,7 @@ def test_handles_invalid_json_gracefully(self, tmp_path: Path) -> None: (map_dir / "step_state.json").write_text("not valid json {") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -233,7 +233,7 @@ def test_allows_edit_during_monitor_phase_by_default(self, tmp_path: Path) -> No """ self._setup_step_state(tmp_path, "master", "MONITOR") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -243,7 +243,7 @@ def test_blocks_edit_during_decompose_phase(self, tmp_path: Path) -> None: """Edit blocked during DECOMPOSE phase.""" self._setup_step_state(tmp_path, "master", "DECOMPOSE") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -253,7 +253,7 @@ def test_blocks_edit_during_predictor_phase(self, tmp_path: Path) -> None: """Edit blocked during PREDICTOR phase.""" self._setup_step_state(tmp_path, "master", "PREDICTOR") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -263,7 +263,7 @@ def test_allows_edit_during_actor_phase(self, tmp_path: Path) -> None: """Edit allowed during ACTOR phase.""" self._setup_step_state(tmp_path, "master", "ACTOR") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -273,7 +273,7 @@ def test_allows_edit_during_apply_phase(self, tmp_path: Path) -> None: """Edit allowed during APPLY phase.""" self._setup_step_state(tmp_path, "master", "APPLY") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -283,7 +283,7 @@ def test_allows_edit_during_test_writer_phase(self, tmp_path: Path) -> None: """Edit allowed during TEST_WRITER phase (TDD mode).""" self._setup_step_state(tmp_path, "master", "TEST_WRITER") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -293,7 +293,7 @@ def test_blocks_write_during_non_editing_phase(self, tmp_path: Path) -> None: """Write blocked like Edit during non-editing phases.""" self._setup_step_state(tmp_path, "master", "PREDICTOR") code, stdout, _ = self.run_hook( - {"tool_name": "Write", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Write", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -303,7 +303,7 @@ def test_blocks_multiedit_during_non_editing_phase(self, tmp_path: Path) -> None """MultiEdit blocked like Edit during non-editing phases.""" self._setup_step_state(tmp_path, "master", "PREDICTOR") code, stdout, _ = self.run_hook( - {"tool_name": "MultiEdit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "MultiEdit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -320,7 +320,7 @@ def test_allows_edit_when_any_subtask_in_actor_phase(self, tmp_path: Path) -> No subtask_phases={"ST-001": "PREDICTOR", "ST-002": "ACTOR"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -335,7 +335,7 @@ def test_blocks_edit_when_no_subtask_in_editing_phase(self, tmp_path: Path) -> N subtask_phases={"ST-001": "PREDICTOR", "ST-002": "DECOMPOSE"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -352,7 +352,7 @@ def test_allows_edit_when_subtask_has_step_id_actor(self, tmp_path: Path) -> Non subtask_phases={"ST-001": "2.3"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -369,7 +369,7 @@ def test_allows_edit_when_subtask_has_step_id_test_writer( subtask_phases={"ST-001": "2.25"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -386,7 +386,7 @@ def test_blocks_edit_when_subtask_has_step_id_research( subtask_phases={"ST-001": "2.2"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -462,7 +462,7 @@ def test_research_phase_message_has_actionable_recovery_commands( """ self._setup_step_state(tmp_path, "master", "RESEARCH") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -623,10 +623,12 @@ def test_research_orthogonal_still_respects_scope_glob( reason = self._assert_denied(stdout) assert "scope_glob" in reason - def test_research_orthogonal_out_of_repo_blocks(self, tmp_path: Path) -> None: - """An out-of-repo path is not a repo-relative affected_files member, but - the relief is conservative and does NOT open arbitrary out-of-repo - writes during RESEARCH — it stays blocked. + def test_research_orthogonal_out_of_repo_allowed(self, tmp_path: Path) -> None: + """#164 (second report): a path outside the repo entirely is + unconditionally orthogonal — no subtask's affected_files can ever + legitimately name a path outside the repo tree it was declared in, + so there's no "current subtask context" for it to belong to. Allowed + during RESEARCH regardless of the subtask's declared affected_files. """ self._setup_step_state(tmp_path, "master", "RESEARCH", subtask_id="ST-001") self._setup_blueprint( @@ -639,7 +641,7 @@ def test_research_orthogonal_out_of_repo_blocks(self, tmp_path: Path) -> None: tmp_path, ) assert code == 0 - self._assert_denied(stdout) + self._assert_allowed(stdout) def test_research_orthogonal_handles_nested_blueprint( self, tmp_path: Path @@ -664,6 +666,98 @@ def test_research_orthogonal_handles_nested_blueprint( assert code == 0 self._assert_allowed(stdout) + # --- Orthogonal relief generalized to ALL blocking phases (#164 second report) --- + + def test_init_state_allows_orthogonal_edit_outside_affected_files( + self, tmp_path: Path + ) -> None: + """#164 (second report): the orthogonal-file relief originally shipped + RESEARCH-only. The identical block recurred during INIT_STATE — an + edit outside ST-001's affected_files must be allowed there too, not + just during RESEARCH. + """ + self._setup_step_state(tmp_path, "master", "INIT_STATE", subtask_id="ST-001") + self._setup_blueprint( + tmp_path, + "master", + [{"id": "ST-001", "affected_files": ["src/app.py"]}], + ) + code, stdout, _ = self.run_hook( + { + "tool_name": "Edit", + "tool_input": {"file_path": str(tmp_path / "src" / "other.py")}, + }, + tmp_path, + ) + assert code == 0 + self._assert_allowed(stdout) + + def test_init_state_blocks_edit_inside_affected_files(self, tmp_path: Path) -> None: + """Counter-test: INIT_STATE still blocks a file IN the current + subtask's affected_files — the relief only lifts the block for + orthogonal files, not for the subtask's own protected surface. + """ + self._setup_step_state(tmp_path, "master", "INIT_STATE", subtask_id="ST-001") + self._setup_blueprint( + tmp_path, + "master", + [{"id": "ST-001", "affected_files": ["src/app.py"]}], + ) + code, stdout, _ = self.run_hook( + { + "tool_name": "Edit", + "tool_input": {"file_path": str(tmp_path / "src" / "app.py")}, + }, + tmp_path, + ) + assert code == 0 + self._assert_denied(stdout) + + def test_init_state_out_of_repo_path_allowed(self, tmp_path: Path) -> None: + """#164 (second report) repro: a MAP session mid-INIT_STATE blocked an + Edit to a path entirely outside the repo (``~/.claude/CLAUDE.md`` + while a session in a *different* repo was active). An out-of-repo + path is unconditionally orthogonal regardless of phase. + """ + self._setup_step_state(tmp_path, "master", "INIT_STATE", subtask_id="ST-001") + code, stdout, _ = self.run_hook( + {"tool_name": "Edit", "tool_input": {"file_path": "/etc/hosts"}}, + tmp_path, + ) + assert code == 0 + self._assert_allowed(stdout) + + def test_decompose_out_of_repo_path_allowed_without_blueprint( + self, tmp_path: Path + ) -> None: + """The out-of-repo relief needs no blueprint/affected_files at all — + it is decided purely by path resolution — so it also fires during + DECOMPOSE, before any subtask work has been scoped. + """ + self._setup_step_state(tmp_path, "master", "DECOMPOSE") + code, stdout, _ = self.run_hook( + {"tool_name": "Edit", "tool_input": {"file_path": "/etc/hosts"}}, + tmp_path, + ) + assert code == 0 + self._assert_allowed(stdout) + + def test_decompose_in_repo_edit_still_blocked_without_blueprint( + self, tmp_path: Path + ) -> None: + """Counter-test: an in-repo path during DECOMPOSE with no blueprint to + derive affected_files stays blocked (conservative default) — only + paths that resolve entirely outside the repo get an unconditional + pass. + """ + self._setup_step_state(tmp_path, "master", "DECOMPOSE") + code, stdout, _ = self.run_hook( + {"tool_name": "Edit", "tool_input": {"file_path": "src/other.py"}}, + tmp_path, + ) + assert code == 0 + self._assert_denied(stdout) + def test_monitor_strict_mode_blocks_edit(self, tmp_path: Path) -> None: """MAP_MONITOR_HOTFIX=0 restores strict read-only MONITOR. The deny message must document the opt-out and the monitor_failed path so @@ -681,7 +775,7 @@ def test_monitor_strict_mode_blocks_edit(self, tmp_path: Path) -> None: ) ) code, stdout, _ = self.run_hook_with_project_dir( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, extra_env={"MAP_MONITOR_HOTFIX": "0"}, ) @@ -713,7 +807,7 @@ def test_respects_branch_scoping(self, tmp_path: Path) -> None: # On master (no state) → fail-open → allow code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, branch="master", ) @@ -736,7 +830,7 @@ def test_branch_name_sanitization( self._setup_step_state(tmp_path, sanitized, "ACTOR") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, branch=branch_name, ) @@ -900,7 +994,7 @@ def test_allows_edit_when_phase_is_complete(self, tmp_path: Path) -> None: """Edit allowed after workflow completes (current_step_phase == 'COMPLETE').""" self._setup_step_state(tmp_path, "master", "COMPLETE") code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0 @@ -948,7 +1042,7 @@ def test_allows_edit_when_subtask_phase_is_complete(self, tmp_path: Path) -> Non subtask_phases={"ST-001": "COMPLETE"}, ) code, stdout, _ = self.run_hook( - {"tool_name": "Edit", "tool_input": {"file_path": "/test.py"}}, + {"tool_name": "Edit", "tool_input": {"file_path": "test.py"}}, tmp_path, ) assert code == 0