Requirements: a repository may bring its own graph hooks
Phase 1 of 3 (requirements → design → tasks). Following the Kiro spec approach (https://kiro.dev/docs/specs/). This phase MUST be reviewed and approved by the required collaborators before moving to design.
Introduction
the-loop's process is extensible by design and closed in practice. Ticket #248 — "allow user's of the-loop to provide their own hooks — users of the-loop's CLI should be able to point to their own hooks as well".
A hook is the-loop's one unit of work at a node boundary: (HookContext) -> HookResult, named in the graph, resolved from a registry (process-graph § The hook contract). Ten ship with the CLI. A project that wants an eleventh — "every design.md must carry our architecture board's sign-off", "no implementation node passes while the licence header is missing", "tell our own change-management system when a work item reaches needs-review" — has nowhere to put it. issue-109 listed "user-defined graphs and user-authored hooks" as out of scope precisely so this could arrive later:
the declarative form and the registry exist so it can arrive safely
This work item delivers the hooks half of that, and only that half. The process graph stays the-loop's — a repository still cannot redefine the PDLC, reorder it, or drop a gate out of it. What a repository gains is the ability to add a check at a boundary the shipped graph already declares, in code it writes, reviews and ships in its own tree.
The direction rule (decision-044) puts the declaration in the repository's harness config: a hook that gates this project's design artifacts is a property of this project, not of the operator's machine, and the same rule already puts reviews.critics[] there. That entry is the precedent in every other respect too — it is executable configuration in a repo-tracked file, reviewed like code (decision-043).
Requirements
Requirement 1 — a repository declares hook modules and where they attach
User story: As an engineer whose project has a rule the-loop does not ship, I want to write that rule as a hook in my own repository and name the boundary it runs at, so that the loop enforces it without my forking the-loop.
Acceptance criteria (EARS)
- WHEN a repository's harness config declares
graph.hooks.modules[]THEN the-loop SHALL load each declared module and register the hooks it defines for that repository. - THE system SHALL accept two ways to name a module:
path, a.pyfile relative to the repository root, andmodule, an importable dotted name installed alongside the CLI. - WHEN a repository declares
graph.hooks.attach[]THEN each entry SHALL name ahook, anodeof the loop being walked, and optionally aboundary(entry|exit, defaultexit) and awithparameter mapping, and the-loop SHALL run that hook at that boundary. - THE system SHALL pass an attached hook the same
HookContextand accept the sameHookResultas a shipped hook — one contract, not two. - WHEN a work item's node has repository hooks attached THEN
the-loop check,the-loop graphand the daemon SHALL all evaluate the same chain, because all three compile the graph through one loader. - WHEN a repository declares no
graph.hooksblock THEN the-loop's behaviour SHALL be byte-for-byte what it is today, loading and importing nothing.
Requirement 2 — a repository hook may add a constraint, never remove one
User story: As the owner of the-loop's process, I want a repository's hooks to be strictly additive, so that adopting them cannot become a way to opt out of the PDLC.
Acceptance criteria (EARS)
- THE system SHALL append attached hooks to the end of a node's shipped chain, and SHALL provide no way to remove, reorder or replace a shipped hook.
- WHERE a shipped hook does not pass, the chain's short-circuit SHALL mean the attached hooks after it do not run — a repository hook SHALL NOT be able to turn a shipped
blockinto apass. - THE system SHALL ignore an
outcomedeclared in an attached hook'sdata, so a repository hook SHALL NOT be able to classify a human gate (e.g.approved) or select an edge; its influence on movement is confined to itsstatus(pass|block|wait|skip). - WHEN an attached hook declares an outcome THEN the-loop SHALL log that it was ignored, naming the hook — a silently dropped value is a defect report nobody receives.
- THE system SHALL NOT let a repository attach to a node the loop being walked does not declare, nor to a boundary other than
entryorexit.
Requirement 3 — a repository hook is named in a reserved namespace
User story: As an engineer reading a chain, I want to know at a glance which hooks are the-loop's and which are my repository's, so that a failure lands on the right maintainer.
Acceptance criteria (EARS)
- THE system SHALL require every repository-provided hook name to begin with
x-. - THE system SHALL refuse a shipped hook registered under an
x-name, so the namespace cannot be colonised from the other side. - WHEN a repository module registers a name without the
x-prefix THEN the load SHALL fail, naming the module and the offending name. - THE system SHALL resolve an
x-name from the repository's own table, so two repositories served by one daemon SHALL NOT see each other's hooks even when both use the same name.
Requirement 4 — every failure is a load failure, and it names the file
User story: As an operator, I want a broken hook declaration to stop the loop with a message, so that a compliance gate can never quietly stop running.
Acceptance criteria (EARS)
- WHEN a declared module is missing, unreadable, raises on import, or registers no hook THEN loading the graph SHALL fail with
GraphConfigErrornaming the declaration. - WHEN an
attach[]entry names a hook no declared module registered THEN loading the graph SHALL fail, listing the names that were registered. - WHEN an attached hook raises at run time THEN it SHALL be treated as
blockwithretriable=False, exactly as a shipped hook is (issue-109, R2.6). - THE system SHALL NOT degrade a broken
graph.hooksblock to "no hooks" — an absent gate that the repository asked for is a false green.
Requirement 5 — the operator can see, and refuse, what a repository would run
User story: As the operator running the daemon, I want to inspect a repository's hook declarations without executing them, and to switch the whole mechanism off for my machine, so that adopting a repository is a decision I make with the facts.
Acceptance criteria (EARS)
- THE CLI SHALL provide
the-loop graph hooks, which reports the registered shipped hooks and the repository's declared modules and attachments without importing any of them. - WHEN
routing.graph.repoHooksisfalsein the operator's CLI config THEN the-loop SHALL load no repository hooks on any path that reads that config, and SHALL say so when a repository declared some. - THE system SHALL resolve a
path:module inside the repository root — an absolute path, a..escape, a symlink leaving the tree, or a non-.pyfile SHALL be refused.
Non-functional requirements
- No new runtime dependency. Module loading is
importlib, stdlib (decision-038). - Loaded once per process. A module is imported once and its hooks cached by source, so a daemon walking N work items in one repository pays for one import. Editing a hook module therefore takes effect on the next process, which the documentation states.
- Observable. The event log records that a repository's hooks were loaded, with the module count and the attachment count — never the module's contents.
Security considerations
Threat-model-lite, captured with the requirements (
security.threatModel.required).
- Actors & trust. Trusted: the-loop's shipped graph and hooks; the operator; the repository's reviewed, committed code. Untrusted: the agent as a writer of files inside a checkout; ticket and webhook payload text; any repository the operator has not adopted.
- Boundary 1 — configuration → code execution. issue-109's Boundary 2 said hooks are registered code and never argv-from-configuration, so the graph could safely become user-authored. This work item does not weaken that: the YAML still names hooks, and the code still arrives as code. What changes is whose code — and that is a trust decision the repository's own reviewers make, in a repo-tracked file, exactly as
reviews.critics[]already is (decision-043). - Boundary 2 — the CLI process. An attached hook runs in the-loop's process, with its environment. That process holds credential handles (env var names, R2.7) but its environment holds the values, so a repository hook can read them. This is not a new grant in the daemon's own threat model — the daemon already spawns a harness in that checkout with permissions bypassed (
the_loop.trust), and that session inherits the same environment — but it is a new route to that place, and one an agent can open by writing a hook module and agraph.hooksblock into a checkout it can already write. That is why R5.2's kill switch and R5.1's no-import inspection exist, and why the answer to "who authorises this?" is a repo-tracked file whose diff a reviewer sees. - Boundary 3 — repository hook → process movement. A hook the agent could write must not be able to advance a work item. Confined by R2.1 (append-only), R2.2 (short-circuit) and R2.3 (no outcome), so the strongest thing a repository hook can do to the loop is stop it.
- Abuse cases (EARS):
- WHEN a repository hook returns
passat a gate whose shipped hook blocked THEN the node SHALL stay blocked, because the shipped hook short-circuited before it ran. - WHEN a repository hook declares
data["outcome"] = "approved"at a human gate THEN the outcome SHALL be ignored and the gate SHALL stay waiting. - WHEN a repository declares a module outside its own tree (
/etc/…,../other-repo/…, a symlink out) THEN the load SHALL be refused. - WHEN a repository module registers a name that shadows a shipped hook THEN the load SHALL fail rather than the shadow taking effect.
- WHEN two repositories register the same
x-name THEN each work item SHALL run its own repository's implementation. - WHEN an attached hook raises or hangs the chain THEN it SHALL be a
block, never apass. - WHEN the operator sets
routing.graph.repoHooks: falseTHEN no repository module SHALL be imported on that machine.
- WHEN a repository hook returns
- Fail closed. A malformed declaration, an unresolvable module, an unknown hook name, an unknown node, an escaping path — each stops the load and reports. Nothing degrades to "no hooks".
- New surface, stated. Repository-supplied Python executes inside the CLI process at node boundaries. Risk tier 4 —
security.review.humanSignOffMinTieris 4, so this work item needs a named human security sign-off before it completes, and the schema and config files it touches are inautonomy.sensitivePaths.
Out of scope
- User-authored graphs. A repository still cannot declare nodes, edges or a loop of its own;
_warn_on_repo_graphstands unchanged. Only the hook half of issue-109's deferred item is delivered. - Removing, replacing or reordering shipped hooks. Additive only, by decision (R2).
- Sandboxing repository hooks. They run in-process, trusted as the repository's own code. A sandbox is a different work item with a different threat model.
- Distributing hooks as a plugin/entry-point ecosystem.
module:accepts an installed dotted name, which is enough for a team topip installa shared package; discovery, versioning and a registry are not built here. - Hot reload. A hook module is imported once per process (see non-functional).
Open questions
None outstanding. The two judgement calls — where the declaration lives (harness config, by decision-044) and whether repository hooks may route (they may not, R2.3) — are answered in design.md § Trade-offs & decisions and recorded as a decision record.
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments.