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.
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:
- emits a short
MODULE CONTEXT: … — UNAVAILABLEblock saying so; - starts a detached render (
start_new_session=True) so the snapshot-keyed cache is warm for next time; - 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):
sh -c 'exec "$(command -v python3.14t \|\| command -v python3)" "$HOME/.claude/hooks/task_context_hook.py"'
Location
- Script:
claude/hooks/task_context_hook.py(repository path at8a2cb42ad) - Deployed:
/home/mohamed/.claude/hooks/task_context_hook.py - Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/8a2cb42ad/claude/hooks/task_context_hook.py
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