task_context_hook.py

Claude / Hooks / task_context_hook

Type: block (can deny or stop) · Events: PreToolUse · Registrations: 1

task_context_hook.py gates every subagent brief and injects structural context into briefs and into the main session's Python edits. It is a PreToolUse hook on Agent, Edit, MultiEdit, Write and NotebookEdit, from the orchestrata plugin registration (the command runs the deployed copy under ~/.claude/hooks), live in every project.

For an Agent call it reads tool_input.prompt. A prompt that already carries the marker === WORKFLOW METHODOLOGY is a re-delegation and passes. Otherwise it checks the brief for the required fields (GOAL, FILES, CONSTRAINTS, DONE WHEN, VERIFY) and denies with the brief template if any is missing. It classifies the task into methodology domains by keyword and builds a block ## <DOMAIN> METHODOLOGY (applies to this task) for each domain not already shown this session, taking .py paths from the brief's FILES: field first. For an existing file it calls module_context.py for the module's AST, class diagram, Prolog dependency contracts and blast radius; for a file that does not exist yet it attaches the public API of the package the file joins. If the task is of a category that requires module structure and no .py path can be resolved the brief is denied. Otherwise it returns permissionDecision: allow with an updatedInput whose prompt ends with === WORKFLOW METHODOLOGY ... CARRIED INTO THIS TASK === and the added blocks. For Edit, MultiEdit, Write and NotebookEdit on a .py file it injects the same module context as additionalContext, once per module per session (a cold render takes about 8.7 seconds, a warm one 0.08). A render that overruns the edit timeout is replaced by a notice and a detached warm-up so the next edit has it; the edit path never returns a permission decision, so it does not short-circuit the permission flow.

Diagram claude-hooks-task_context_hook-m5fc2892d60

Known defects: a cold render adds several seconds to the first edit of each module; the hook depends on module_context.py being present in the plugin tools directory, and denies with a layout message when it is missing. To satisfy it, write briefs with the five fields and a FILES: line naming repo-relative .py paths; see briefs. Related: require-design-before-build (the design gate on the same Agent call), workflow-default-sonnet, RUL-041, RUL-026, RUL-022.

Earlier documentation

Source: claude/hooks/task_context_hook.py (orchestrata-ultimate-architecture) · PreToolUse · Python 3

The richest context layer: it puts a module's full AST, Prolog-derived blast radius, dependency contracts and related wiki/RAG passages in front of work that is about to change that module.

Two routes, deliberately different

Tool What it does Permission flow
Agent Rewrites the dispatched brief's prompt, appending the carried context allow + updatedInput
Edit / MultiEdit / Write / NotebookEdit Emits additionalContext for the main session untouched

The edit route deliberately does not return a permissionDecision. Emitting allow there would short-circuit the permission prompt for every edit in the session as a side effect of adding context — a far larger change than the one being made.

Registration lives in claude/settings.hooks.json. The matcher and _EDIT_TOOLS are pinned to each other by test_every_tool_the_hook_handles_is_actually_registered_for_it, because they have already drifted: _inject_for_edit was written, tested and deployed while the matcher still read "matcher": "Agent", so it never once fired for an edit.

Why the edit route exists

Until 2026-08-19 only Agent was matched, so AST + contracts + blast radius reached delegated work and nothing else. A session editing its own files — or one whose instructions forbid spawning agents — never saw the richest layer at all. Measured cost: claude/tools/module_context.py was edited without its contracts, and the ast-sync name collision (two different programs answering to one console-script name) survived a commit, a review and a ship.

Timeouts, and why there are two

Constant Value Route
MODULE_TIMEOUT 30s Agent — runs once per dispatch, can afford it
EDIT_TIMEOUT 10s edits — sit in front of a keystroke-scale action

Measured 2026-08-19: a cold render is 8.71s, a warm one 0.08s, and claude/hooks/test_run_context.py took 48.16s.

An overrun reports itself

_run_module_context re-raises subprocess.TimeoutExpired rather than flattening it. Before that, a timeout and "this module has no context" both came back as "", so the 48.16s render injected nothing, logged nothing, and was indistinguishable from a module the tool had nothing to say about.

On overrun the edit route now:

  1. emits a short MODULE CONTEXT: … — UNAVAILABLE block saying so;
  2. starts a detached render (start_new_session=True) so the snapshot-keyed cache is warm for next time;
  3. does not record the per-session ledger key — so the next edit of that module renders again and collects the warm cache, instead of being skipped for the rest of the session.

The warm helper is not wrapped in try/except: a failed spawn is a real condition, and main()'s boundary already guarantees a hook cannot break the tool call, so a local catch would only hide the cause.

Dedup ledger

One injection per module per session, keyed on the resolved path and stored under $XDG_RUNTIME_DIR/claude-inject-ledger. Both routes share it, so the Agent and Edit paths cannot double up on the same module.

Path resolution

FILES: block paths win over prose mentions. A 2026-08-19 defect had three compounding bugs here and none of them failed loudly — the hook reported success while injecting the wrong module's context, and once handed an auditor the public API of four unrelated scripts sitting loose in $HOME. test_files_block_paths_win_over_prose_mentions is the permanent regression.

Tests

tests/scripts/test_task_context_hook.py — drives the real hook as a real subprocess (JSON on stdin, decision JSON on stdout), which in turn shells out to the real module_context.py against real fixture files. No mocks.

Registration

# Event Matcher Timeout Registered in Live in a $HOME session
49 PreToolUse Agent|Edit|MultiEdit|Write|NotebookEdit default orchestrata plugin 1.5.0 hooks.json:350 yes

Commands (as registered, abridged):

Location

Source

task_context_hook.py (deployed copy)

530 lines.

#!/home/mohamed/.claude/.venv/bin/python
"""Route every delegated task through the same categorizer, and inject once.

User instruction (2026-08-13): "make sure the task categorization works and that
every subagent or task runs through it and context gets injected without
redundancy."

Three things, in that order.

**One categorizer.** The domain patterns live in `workflow_context_inject.DOMAINS`
and are IMPORTED here rather than restated. A second copy would drift, and then
"the categorization" would mean two different things depending on which path a
task took — the failure this session hit repeatedly with orchestrata-scoped tools.

**Every subagent runs through it.** A subagent is a fresh session with none of the
parent's context, so the methodology has to travel WITH the task. PreToolUse on
`Agent` rewrites the tool's own `prompt` via `updatedInput` — the same mechanism
route_pytest_to_threads.py uses to reroute a command — so the brief the subagent
receives already carries its categorized methodology and, when the brief names a
module, that module's full AST plus Prolog-inferred dependency contracts.

**Without redundancy.** Two guards. A prompt that already contains the marker is
left alone (an explicit re-delegation of an already-enriched brief). And each
distinct block is hashed into a per-session ledger, so the same module context is
never injected twice into the same session by two different paths.

Never blocks: any failure returns the tool call unmodified.
"""

from __future__ import annotations

import hashlib
import json
import os
import re
import subprocess
import sys
from pathlib import Path

HOOKS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(HOOKS_DIR))

# Tree-relative, not a literal ~/.claude: ${CLAUDE_PLUGIN_ROOT} (a plugin's
# install dir) when set, else HOOKS_DIR walked up to the tree root — so this
# resolves its sibling tools/ whether deployed to ~/.claude or shipped as a
# Claude Code plugin.
_PLUGIN_ROOT = Path(os.environ.get("CLAUDE_PLUGIN_ROOT") or HOOKS_DIR.parent)
MODULE_CONTEXT = _PLUGIN_ROOT / "tools" / "module_context.py"
MARKER = "=== WORKFLOW METHODOLOGY"
MODULE_MARKER = "=== MODULE CONTEXT"
#: What module_context.py emits for a path that does not exist yet — the
#: package the new module joins, not an AST. A different header, so a guard
#: that only knows MODULE_MARKER silently drops every new-file render.
NEW_FILE_MARKER = "## NEW FILE:"
LEDGER_DIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / "claude-inject-ledger"
MODULE_TIMEOUT = 30
#: The Agent route runs once per dispatch and can afford MODULE_TIMEOUT. The
#: EDIT route runs in front of a keystroke-scale action, so it caps much lower:
#: a cold render measured 8.71s and a warm one 0.08s (2026-08-19), while
#: claude/hooks/test_run_context.py took 48.16s — long enough that blocking on
#: it reads as a hung editor. Over the cap the edit path warms the cache in the
#: background and says so, rather than stalling or claiming there is no context.
EDIT_TIMEOUT = 10

try:
    from workflow_context_inject import _COMPILED, DOMAINS  # one categorizer, imported
except Exception:  # noqa: BLE001 - never break the tool call over an import
    DOMAINS, _COMPILED = {}, {}

#: Every agent spawn must fill these. Not style — each one is a failure this
#: session or an earlier retro actually paid for: briefs with "minimal info"
#: rebuilt the wrong thing, unstated done-criteria produced "mostly done", and
#: an unnamed verification command produced claims nobody had run.
REQUIRED_FIELDS = ("GOAL:", "FILES:", "CONSTRAINTS:", "DONE WHEN:", "VERIFY:")

#: Categories where the module's AST is mandatory, not advisory. A coding or
#: testing agent that cannot see the structure it is changing guesses at it.
AST_REQUIRED_CATEGORIES = {"development", "testing"}

BRIEF_TEMPLATE = """GOAL: <one sentence: the outcome, not the activity>
FILES: <exact paths this task may touch — at least one .py for a coding task>
CONSTRAINTS: <what must NOT change; MVP scope; no mid-flight additions>
DONE WHEN: <the observable condition, with numbers where numbers exist>
VERIFY: <the exact command that proves it, e.g. `pytest-live tests/unit/test_x.py -q`>"""


def _emit(**fields: object) -> None:
    """Write one PreToolUse hook response on stdout.

    The envelope was inlined at three call sites, which is three chances for
    one of them to drift — and the shape is load-bearing: Claude Code reads
    nothing if ``hookEventName`` is absent or misspelled, and it fails silently,
    with the hook still exiting 0. One writer, three callers.
    """
    print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", **fields}}))


def _deny(reason: str) -> None:
    _emit(permissionDecision="deny", permissionDecisionReason=reason)


_PATH_RE = re.compile(r"[\w./-]+\.py\b")

#: The brief's own field markers (see BRIEF_TEMPLATE / REQUIRED_FIELDS) —
#: used to slice the FILES: field out of the rest of the prompt.
_OTHER_FIELD_RE = re.compile(
    "|".join(re.escape(f) for f in REQUIRED_FIELDS if f != "FILES:"), re.IGNORECASE
)


def _files_block(prompt: str) -> str:
    """The text of the brief's ``FILES:`` field, or ``""`` if the prompt
    carries no such field.

    Real briefs in this project put narrative prose BEFORE the FILES: block
    (see BRIEF_TEMPLATE) — scanning the WHOLE prompt for ``.py`` mentions
    routinely exhausts the six-path budget on files merely named in prose,
    so the actual FILES: list is never reached (2026-08-19: an auditor
    dispatched to examine six specific repo files got the public API of
    four unrelated scripts instead). Slicing to the field itself fixes
    that; ``module_context_for`` falls back to the whole prompt only when
    this comes back empty (no FILES: marker, or a FILES: field that names
    no ``.py`` path of its own).
    """
    match = re.search(r"FILES:", prompt, re.IGNORECASE)
    if match is None:
        return ""
    rest = prompt[match.end() :]
    next_field = _OTHER_FIELD_RE.search(rest)
    return rest[: next_field.start()] if next_field else rest


def _git_root(cwd: str) -> Path | None:
    """Walk up from ``cwd`` looking for a ``.git`` entry (a directory for a
    normal checkout, a file for a linked worktree) — the project root for a
    session whose cwd landed somewhere other than the repo (routinely
    ``$HOME`` — see ``module_context_for``'s docstring).
    """
    try:
        start = Path(cwd).resolve()
    except OSError:
        return None
    for candidate in (start, *start.parents):
        if (candidate / ".git").exists():
            return candidate
    return None


def _candidate_bases(cwd: str) -> list[Path]:
    """Ordered base directories a non-absolute FILES: path resolves against.

    ``CLAUDE_PROJECT_DIR`` (the project the brief is actually FOR) first,
    then the git root walked up from ``cwd``, then ``cwd`` itself as the
    last resort — ``cwd`` is routinely NOT the repo (see the module
    docstring). The first base under which a candidate exists wins;
    ``module_context_for`` falls back to the first base regardless when no
    base has the file, so a ``--new-file`` lookup still has somewhere to
    resolve against.
    """
    bases = []
    project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
    if project_dir:
        bases.append(Path(project_dir))
    git_root = _git_root(cwd)
    if git_root is not None:
        bases.append(git_root)
    bases.append(Path(cwd))
    return bases


def _resolve_candidate(candidate: str, bases: list[Path]) -> Path:
    """Resolve one ``.py`` mention against ``bases``, first hit wins."""
    path = Path(candidate)
    if path.is_absolute():
        return path
    for base in bases:
        resolved = base / candidate
        if resolved.is_file():
            return resolved
    return bases[0] / candidate


def categorize(text: str) -> list[str]:
    """The domains a task touches — the single shared classification."""
    return [name for name, rx in _COMPILED.items() if rx.search(text)]


def _ledger(session: str) -> Path:
    safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session or "nosession")
    return LEDGER_DIR / f"{safe}.json"


def _seen(session: str) -> set[str]:
    try:
        return set(json.loads(_ledger(session).read_text()))
    except OSError, ValueError:
        return set()


def _remember(session: str, digests: set[str]) -> None:
    try:
        LEDGER_DIR.mkdir(parents=True, exist_ok=True)
        path = _ledger(session)
        path.write_text(json.dumps(sorted(_seen(session) | digests)))
    except OSError:
        pass


def _digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:16]


def _run_module_context(args: list[str], timeout: float = MODULE_TIMEOUT) -> str:
    try:
        done = subprocess.run(
            [sys.executable, str(MODULE_CONTEXT), *args],
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        # NOT the same as "nothing to say", and it must not read that way.
        # A render that overran used to come back as "" — indistinguishable
        # from a module with no context — so the injection silently produced
        # nothing and left no trace anywhere. Measured 2026-08-19:
        # claude/hooks/test_run_context.py renders in 48.16s against a 30s
        # cap, so every edit of it injected nothing and looked like the hook
        # had never run. The caller decides what to say; this only refuses to
        # lie about which happened.
        raise
    except OSError, subprocess.SubprocessError:
        return ""
    return done.stdout.strip() if done.returncode == 0 else ""


def _warm_module_context(args: list[str]) -> None:
    """Fire-and-forget render so the NEXT edit of this module is instant.

    module_context.py keys its cache on the AST snapshot, and the difference
    is not marginal: 8.71s cold against 0.08s warm (measured 2026-08-19). The
    edit path therefore does not wait for a cold render — it starts one,
    detached, and injects on the following edit. Detached deliberately: this
    process exits as soon as the hook returns, and a child in the same process
    group would be killed with it.

    Not wrapped in try/except: a failure to spawn is a real condition, and
    swallowing it here would make a permanently cold cache look like a warm
    one that simply never helps. main()'s boundary already guarantees a hook
    cannot break the tool call, so there is nothing a local catch would add
    beyond hiding the cause.
    """
    subprocess.Popen(  # noqa: S603 - fixed argv, no shell
        [sys.executable, str(MODULE_CONTEXT), *args],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        stdin=subprocess.DEVNULL,
        start_new_session=True,
    )


def module_context_for(prompt: str, cwd: str) -> str:
    """Structure for the module this task touches.

    An EXISTING path gets the full treatment: AST, class diagram, Prolog
    dependency contracts, blast radius.

    A path that does not exist yet gets the package it joins — sibling public
    API only. The gate used to deny those outright, on the grounds that no
    resolvable .py path was found, which was wrong about its own purpose: the
    point of injecting structure is that an agent can see what it must fit
    into, and for a new module that is the package, not the empty file.
    Measured need, not hypothetical — both scope-inference agents on
    2026-08-14 created only new files, and the one thing each had to be told
    by hand was the other's API surface.

    Existing files win: if the brief names both, the real AST is the better
    context and the package listing is redundant.

    Path SOURCE and RESOLUTION are the two things a caught-red-handed defect
    (2026-08-19) got wrong:

    - .py mentions in the brief's ``FILES:`` field are preferred over the
      whole prompt (``_files_block``) — prose before FILES: (the normal
      shape of a brief) was routinely burning the six-path budget on files
      merely MENTIONED, so FILES: itself was never reached.
    - a non-absolute candidate resolves against ``CLAUDE_PROJECT_DIR``, then
      the git root walked up from ``cwd``, then ``cwd`` itself
      (``_candidate_bases`` / ``_resolve_candidate``) — ``cwd`` here is
      routinely ``$HOME``, not the repo, so a bare ``cwd``-only resolution
      missed every repo-relative path.
    - a ``--new-file`` candidate whose package (``path.parent``) resolves to
      ``$HOME`` is treated as NO candidate, not as one to render — the
      caught-red-handed case: an auditor asked to look at six repo files got
      the public API of four unrelated scripts sitting loose in
      ``/home/mohamed`` instead, and the hook reported success. Injecting
      nothing is strictly better than injecting the wrong package.
    """
    if not MODULE_CONTEXT.is_file():
        return ""

    files_block = _files_block(prompt)
    raw = _PATH_RE.findall(files_block)[:6] if files_block else []
    if not raw:
        raw = _PATH_RE.findall(prompt)[:6]

    bases = _candidate_bases(cwd)
    candidates = [_resolve_candidate(c, bases) for c in raw]

    for path in candidates:
        if path.is_file() and (out := _run_module_context([str(path)])):
            return out

    home = Path.home()
    for path in candidates:
        if path.is_file():
            continue
        try:
            package = path.resolve().parent
        except OSError:
            package = path.parent
        if package == home:
            continue  # loose scripts in $HOME are not context -- see above
        if out := _run_module_context(["--new-file", str(path)]):
            return out
    return ""


#: File-editing tools whose target module is worth injecting for the MAIN
#: session. Keyed by the tool_input field naming the file.
_EDIT_TOOLS = {
    "Edit": "file_path",
    "MultiEdit": "file_path",
    "Write": "file_path",
    "NotebookEdit": "notebook_path",
}


def _inject_for_edit(payload: dict, tool: str) -> int:
    """Give the main session the same module context a subagent brief gets.

    Until now this hook only rewrote the ``Agent`` tool's prompt, so the AST +
    Prolog contracts + blast radius reached DELEGATED work and nothing else. In
    a session that does its own editing — or one whose instructions forbid
    spawning agents — the richest injection layer never fired at all. Measured
    2026-08-19, the concrete cost: `claude/tools/module_context.py` was edited
    without its contracts and the `ast-sync` name collision (two different
    programs answering to one console-script name) went unseen through a
    commit, a review and a ship.

    Cost, measured on this machine before wiring it: a cold render is 8.71s for
    a module the snapshot-keyed cache has not seen, and 0.08s once it has. The
    per-session ledger below means each module is paid for once, so a session
    that edits the same file repeatedly — the normal shape — pays once and then
    nothing. A render that overruns MODULE_TIMEOUT returns empty and the edit
    proceeds untouched; this must never be the reason a tool call fails.

    Deliberately does NOT return a permissionDecision. Emitting "allow" here
    would short-circuit the permission flow for every Edit in the session as a
    side effect of adding context, which is a much larger change than the one
    being made.
    """
    field = _EDIT_TOOLS[tool]
    target = ((payload.get("tool_input") or {}).get(field) or "").strip()
    if not target.endswith(".py"):
        return 0

    path = Path(target)
    if not path.is_absolute():
        path = _resolve_candidate(target, _candidate_bases(payload.get("cwd") or ""))

    # A file that does not exist yet gets the package it JOINS — sibling public
    # API only — exactly as the Agent route does via --new-file. The asymmetry
    # was mine and it was wrong for the same reason the Agent gate's was: the
    # point of injecting structure is seeing what the new code has to fit into,
    # and for a module that does not exist that is the package, not an empty
    # file. Writing a new module beside eight siblings whose API you cannot see
    # is how a ninth spelling of an existing helper gets added.
    args = [str(path)] if path.is_file() else [str(path), "--new-file"]

    session = payload.get("session_id") or "no-session"
    seen = _seen(session)
    key = _digest(f"module::{path.resolve()}")
    if key in seen:
        return 0  # already injected for this module in this session

    try:
        block = _run_module_context(args, timeout=EDIT_TIMEOUT)
    except subprocess.TimeoutExpired:
        # Over the cap: start the render again detached so it populates the
        # snapshot-keyed cache, and say what happened. The ledger key is NOT
        # recorded, so the next edit of this module retries and hits the warm
        # cache instead of being permanently skipped. Reporting the timeout is
        # the point — a silent "" here is what made a 48.16s render look
        # identical to a module with nothing to say.
        _warm_module_context(args)
        _emit(
            additionalContext=(
                f"=== MODULE CONTEXT: {path} — UNAVAILABLE ===\n"
                f"The render exceeded {EDIT_TIMEOUT:g}s and was not injected. A "
                f"warm render is now running in the background; the next edit of "
                f"this module will carry its AST, blast radius and contracts. To "
                f"see it now: python3 {MODULE_CONTEXT} {' '.join(args)}"
            )
        )
        return 0

    if not block or not (MODULE_MARKER in block or NEW_FILE_MARKER in block):
        return 0

    _remember(session, {key})
    _emit(additionalContext=block)
    return 0


def main() -> int:
    try:
        payload = json.load(sys.stdin)
    except json.JSONDecodeError, ValueError:
        return 0

    tool = payload.get("tool_name") or ""
    if tool in _EDIT_TOOLS:
        return _inject_for_edit(payload, tool)
    if tool != "Agent":
        return 0

    tool_input = dict(payload.get("tool_input") or {})
    prompt = tool_input.get("prompt") or ""
    if not prompt.strip() or MARKER in prompt:
        return 0  # already enriched: re-delegation, not a fresh brief

    categories = categorize(prompt)
    missing = [f for f in REQUIRED_FIELDS if f not in prompt.upper()]
    if missing:
        _deny(
            "Agent brief is missing required field(s): "
            + ", ".join(missing)
            + ".\n\nEvery spawn uses this structure:\n\n"
            + BRIEF_TEMPLATE
            + "\n\nA brief without these is how a builder rebuilds the wrong thing, "
            "reports 'mostly done', or claims a verification nobody ran."
        )
        return 0

    session = payload.get("session_id") or ""
    cwd = payload.get("cwd") or os.getcwd()
    seen, fresh_digests, additions = _seen(session), set(), []

    for name in categories:
        *_, summary = DOMAINS[name]  # (doc, patterns, summary) — only the last
        block = f"## {name.upper()} METHODOLOGY (applies to this task)\n{summary}"
        d = _digest(block)
        if d in seen:
            continue
        additions.append(block)
        fresh_digests.add(d)

    mod = module_context_for(prompt, cwd)
    if (
        not mod
        and (set(categories) & AST_REQUIRED_CATEGORIES)
        and MODULE_MARKER not in prompt
    ):
        # Two very different failures share this branch, and blaming the
        # brief for a broken delivery layout sent a whole session chasing
        # FILES formats (2026-08-14: the plugin mirrored tools/ under
        # another name, MODULE_CONTEXT resolved to a nonexistent path, and
        # this deny claimed "FILES names no .py path" against briefs whose
        # paths were fine). Name the real defect when the TOOL is absent.
        if not MODULE_CONTEXT.is_file():
            _deny(
                f"module_context.py is MISSING at {MODULE_CONTEXT} — module "
                "structure cannot be attached because the hook's delivery "
                "layout is broken (hooks resolve ${CLAUDE_PLUGIN_ROOT}/tools/"
                "; regenerate and redeploy the plugin), NOT because the "
                "brief is wrong. Fix the layout, or embed a '=== MODULE "
                "CONTEXT' block in the brief to proceed."
            )
            return 0
        _deny(
            "This is a "
            + "/".join(sorted(set(categories) & AST_REQUIRED_CATEGORIES))
            + " task, so the brief must carry the AST of the module being changed — "
            "and FILES names no .py path at all.\n\n"
            "Name the files in FILES (repo-relative or absolute). An existing file "
            "gets its AST, class diagram and Prolog-inferred dependency contracts "
            "attached automatically; a file that does not exist yet gets the public "
            "API of the package it joins, so a new module fits the surface its "
            "neighbours expose. Either way the agent sees the structure — a coding "
            "agent that cannot see it guesses at it."
        )
        return 0
    if mod and MODULE_MARKER not in prompt:
        d = _digest(mod)
        if d not in seen:
            additions.append(mod)
            fresh_digests.add(d)

    if not additions:
        return 0

    tool_input["prompt"] = (
        prompt
        + "\n\n"
        + MARKER
        + " — CARRIED INTO THIS TASK ===\n"
        + "\n\n".join(additions)
        + "\n=== END CARRIED CONTEXT ==="
    )
    _remember(session, fresh_digests)

    _emit(
        permissionDecision="allow",
        updatedInput=tool_input,
        permissionDecisionReason=(
            "carried categorized context into the subagent brief: "
            + ", ".join(
                ["methodology"] * bool(len(additions) - (1 if mod else 0))
                + (["module AST + Prolog contracts"] if mod else [])
            )
        ),
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception:  # noqa: BLE001 - a hook must never break the tool call
        raise SystemExit(0) from None