<!-- Authored per the the-loop:writing skill. -->
Requirements: programmatic hooks around the lifecycle of a work item's delivery
Phase 1 of 4 (requirements → design → testing plan → 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
Issue-344 asks for hooks around the lifecycle of a work item's delivery — work started, a session spawned, the loop waiting on a person, the work item complete — with an interface that lets the hook author change the outcome as well as observe it, implementable locally or remotely through an SDK the-loop provides, and declared in the operator's cli-config.yaml. The model the ticket points at is sherma's hook system: a fixed set of named hook points, one typed context per point, an executor that returns None to pass the context through or a modified context to replace it, a chain of executors, and one remote protocol (JSON-RPC) over which the same executor can be hosted.
The-loop has one hook system already, and it is not this one. A graph hook (issue-109, issue-248) is a check at a node boundary: (HookContext) -> HookResult, pass | block | wait | skip, the thing that decides whether a work item moves from design to test-planning. It answers "is this gate satisfied?". It cannot say "a session is about to start; here is the prompt, change it", because no node boundary is where a session starts, and its return type has no room for a changed prompt.
A first attempt at this ticket (PR #357, closed) attached hooks to every event the-loop records — about 150 event-log types as attach points, dispatched asynchronously on a worker thread, observation only. The owner closed it and wrote on the ticket: "it was a disaster because it was centered around 'events'. Let's not make the same mistake again." The failure is structural, not cosmetic: an event is a record of something that already happened, so a hook on one can never change it; an event catalog is a log schema, so its field names, levels and count are the log's concern and drift with it; and "every event" is not a lifecycle, it is a firehose in which the four moments the ticket names are indistinguishable from poll.cycle_started.
This work item delivers what the ticket asks for as a curated, typed lifecycle: seven named hook points at the moments a work item's delivery actually turns, each with a context whose facts the hook reads and whose decisions the hook may change, run synchronously in the path of the thing they decide, by executors that are local Python or a remote JSON-RPC server, declared once in the CLI config, authored against one SDK.
Requirements
R1 — the lifecycle is a fixed set of named points, not the event catalog
User story: As an operator, I want to attach code to the moment a work item starts and the moment it completes, so that my telemetry and my policies key on the lifecycle of the work, not on the shape of the-loop's log.
Acceptance criteria (EARS)
- The system SHALL define a fixed catalog of lifecycle points, each with a name, a typed context and a description, shipped in the CLI:
work_item_start,session_spawn,session_spawned,waiting_for_input,input_received,phase_changed,work_item_complete. The catalog SHALL be listable (the-loop hooks points) and SHALL be the only set of names a declaration may reference. - A point SHALL fire synchronously, in the code path of the thing it describes, before that thing is done wherever the point carries a decision about it:
work_item_startbefore the work item's workspace is prepared or any session exists;session_spawnbefore the harness process is launched;session_spawnedafter the session is registered and before the-loop announces it;waiting_for_inputbefore the question is posted (an agent'sthe-loop ask) and when a human gate is entered;input_receivedwhen a person's input reaches the loop — an answer, a comment or review to deliver, a control command — before it is delivered, read by a gate or run;phase_changedwhen the graph's phase label changes or a terminal node is reached, before the channels are told;work_item_completewhen the ticket or pull request that is the work item closes, before the closure is announced. - The event-log catalog (
EVENT_TYPES) SHALL NOT be a source of hook points. Adding an event type SHALL NOT add a hook point; a declaration naming an event type SHALL fail to load, naming the valid points. - Adding a point SHALL require exactly: one context type, one method on the base executor, one call site — and SHALL fail the suite until the catalog, the SDK base class and the documentation agree (a parity test).
R2 — every point has one interface: facts in, decisions out
User story: As a hook author, I want one shape to learn — a typed context I receive and either return unchanged or return modified — so that a policy hook and a telemetry hook are written the same way and I can tell exactly what I am allowed to change.
Acceptance criteria (EARS)
- Each context SHALL be a dataclass of facts (read-only: the work item as a modelled entity — its ref, tracker, host, repository, number, kind, URL and id — the loop, the harness, the node…) and decisions (the fields the-loop reads back:
proceed,reason,prompt,announce,question,summary,notify). Every field SHALL carry a JSON-serialisable value (str,bool,int, a list of strings). - An executor method SHALL receive the context and SHALL return
None(pass through unchanged) or a context of the same type (replace). The system SHALL read back only the decision fields; a changed fact SHALL be ignored and the drop logged. - Executors SHALL run in declaration order; each SHALL see the decisions the earlier ones made. The decisions after the last executor SHALL be what the-loop applies.
- A decision SHALL keep its declared type: a
booldecision set to a non-boolean, or astrdecision set to a non-string, SHALL be treated as that executor's failure (R6), never coerced. - Every point SHALL document which decision it applies and how; the table in the design and the SDK docstrings SHALL be the same table (parity-tested).
proceed: falseatwork_item_startSHALL refuse the start: the work item is disarmed, one marked comment on the ticket names the hook and itsreason, the event is settled (no retry), andhooks.refusedis recorded.proceed: falseatsession_spawnSHALL prevent that launch, with the same record and comment; for a pull request's own session the event SHALL be delivered into the work item's session instead, as it is when no checkout exists for the pull request.promptatsession_spawnSHALL replace the text handed to the harness on launch.announce: falseatsession_spawnedand atwork_item_completeSHALL suppress the-loop's own announcement of that fact (the session announcement comment; the closure message on the channels).notify: falseatphase_changedSHALL suppress thephase.*lifecycle publish to the channels for that transition.questionandsummaryatwaiting_for_inputSHALL replace the text posted for an agent's question; for a human gate the request text is the graph's, so those fields SHALL be empty and a change SHALL be ignored.
R3 — declared once, in the operator's CLI config
User story: As an operator, I want to name my hooks in cli-config.yaml, beside my critics and my graphs, so that what runs with my credentials is decided in my file and nowhere a pull request can edit.
Acceptance criteria (EARS)
- The CLI config SHALL accept a top-level
hooks, a list of entries. Each entry SHALL carry aname(^[a-z][a-z0-9-]*$, unique) and exactly one ofmodule(an installed dotted module),path(a.pyfile — absolute,~-relative, or relative to the directory of the CLI config file in effect, never a checkout) orurl(a remote JSON-RPC endpoint). An absent or empty list SHALL change nothing. - A local entry MAY carry
executor(the attribute in the module to use) andwith(keyword arguments to its constructor). A remote entry MAY carrytokenEnv(the name of an environment variable whose value is sent as a bearer token),headers(non-secret request headers), andtimeoutSeconds(default 10). - Any entry MAY carry
on(the subset of points it handles; default: every point the local executor overrides, every point for a remote one),required(defaultfalse, R6.3) andenabled(defaulttrue). - WHEN an entry is malformed — a missing or duplicate
name, none or several ofmodule/path/url, an unknown key, anonnaming a point that does not exist, atokenEnvorheaderson a local entry, anexecutororwithon a remote one — THEN loading the declaration SHALL fail naming the entry, never degrade to "no hooks". - WHEN a declared module cannot be imported, defines no
LifecycleHookssubclass (or several, with noexecutorto choose), names anexecutorthat is not one, or raises on construction THEN loading SHALL fail naming the entry. - A daemon (the poller, the webhook receiver, the control-plane service) SHALL load the declaration at start and SHALL refuse to start on a declaration that fails to load, as it refuses an unknown poll provider. A one-shot command (
the-loop ask,the-loop graph complete) SHALL load it on first use; a load failure there SHALL be recorded (hooks.load_failed) and the command SHALL proceed with no hooks — the daemon holding the same file has already refused to start. - The declaration SHALL be read once per process. An edited module or declaration takes effect at the next start of that process.
R4 — the same executor runs locally or remotely
User story: As a platform team, I want to run our compliance hook as a service our security team owns, and our formatting hook as a file next to the config, so that where a hook runs is a deployment choice and not a rewrite.
Acceptance criteria (EARS)
- A local executor SHALL be a subclass of
LifecycleHooksdefining one method per point it handles; it SHALL run inside the-loop's process, synchronously. - A remote executor SHALL be reached by one
POSTper point invocation carrying a JSON-RPC 2.0 request —methodthe point's name,paramsthe context's fields, an integerid— and SHALL read back a JSON-RPC response whoseresultisnull(pass through) or an object whose decision fields replace the context's; other keys and anerrormember SHALL be handled per R6. - The request SHALL carry
Authorization: Bearer <value of $tokenEnv>whentokenEnvis set and SHALL fail (R6) rather than send when the variable is unset or empty. AurlSHALL behttps://…, orhttp://to a loopback host;http://to any other host with atokenEnvSHALL be refused at load — a bearer token is never sent in clear over a network. - The SDK SHALL provide the server half:
handle_request(executor, body) -> bytesimplementing the protocol for any HTTP framework, andHookServer, a stdlib HTTP server hosting one executor with an optional bearer check — so the same class an operator declared bypathcan be hosted byurlunchanged. - The SDK (
the_loop.sdk.hooks) SHALL export the base class, every context type, the catalog,handle_requestandHookServer, and SHALL import no FastAPI, uvicorn or MCP module.
R5 — an operator can see what is declared without running it
User story: As an operator, I want to read what would run — and where — before a work item does, so that a typo in a URL or a module name is found by me, not by a ticket.
Acceptance criteria (EARS)
the-loop hooksSHALL list the declared entries — name, kind (module/path/url), target, points,required,enabled— without importing a module or contacting a URL, and SHALL exit1naming the error when the declaration cannot be read.the-loop hooks pointsSHALL list the catalog: each point, when it fires, its facts and its decisions.- Every run of the chain SHALL be observable in the event log:
hooks.decidedwhen an executor changed a decision (point, hook, the changed fields),hooks.failedwhen one failed (point, hook, error),hooks.refusedwhen aproceed: falsewas applied,hooks.loaded/hooks.load_failedat load.
R6 — a failing hook is loud, never blocking by accident
User story: As an operator, I want a telemetry endpoint outage to cost me a warning, not a stopped work item — and my compliance gate to stop the work item when it cannot be reached, because I said so.
Acceptance criteria (EARS)
- WHEN an executor raises, times out, returns a malformed result or a wrong-typed decision, or a remote returns an
errormember or a non-2xx status THEN the system SHALL recordhooks.failedand continue with the context unchanged by that executor; the remaining executors SHALL still run; the operation SHALL proceed. - A hook SHALL NOT be able to raise into the-loop: no exception from the chain SHALL reach a dispatch, a graph advance or an
ask. - WHEN an entry is
required: trueand it fails at a point that carries aproceeddecision THEN the system SHALL setproceedtofalsewith areasonnaming the hook and the error — the refusal path of R2.6. At a point with noproceed,requiredhas no further effect beyond the record. - A remote invocation SHALL be bounded by
timeoutSeconds; a local one is in-process and is not.
R7 — the-loop's own behaviour is consultable through the same seam
User story: As the-loop's maintainer, I want the announcements the-loop already makes to ask the hooks first, so that the seam is proven by the-loop using it rather than described for others to use.
Acceptance criteria (EARS)
- The session announcement comment, the closure announcement on the channels and the
phase.*lifecycle publishes SHALL consult the corresponding decision (announce,announce,notify) and SHALL be skipped when it isfalse. With no hooks declared each SHALL behave exactly as before. - Nothing else the-loop does SHALL change for an operator who declares no hooks: the dispatch path, the graph runtime and
askSHALL be byte-for-byte the same in effect, and the whole existing suite SHALL pass unchanged.
Non-functional requirements
- Latency. A chain runs in the dispatch path. A local hook is the author's cost; a remote one is bounded by
timeoutSeconds(default 10 s) per executor per point. The spawn path already spends a minute on a checkout; a gate advance is the daemon's, not a human's wait. - Dependencies. None added: the remote client is
urllib, the server ishttp.server, JSON-RPC isjson. The minimalism ladder stops at stdlib. - Compatibility. No CLI config version bump:
hooksis a new optional key; no migration. - Observability. Every load, decision change, failure and refusal is an event in the existing log (R5.3).
Security considerations
- Actors & trust:
- The operator — trusted. They own the CLI config and every module, file and URL it names, as they own
critics[],routing.graph.hooksandgraphs[](decision-043, decision-096, decision-123, decision-136). - A remote hook server — trusted to the extent the operator declared it: it receives the facts of the work item and may set decisions. It is the operator's service.
- An authorized user on a ticket, an unauthorized commenter, the agent (prompt-injectable; runs
the-loop askandthe-loop graph complete) and repository content — untrusted. None of them may declare a hook.
- The operator — trusted. They own the CLI config and every module, file and URL it names, as they own
- Trust boundaries & data:
- Configuration → code execution. A
module/pathentry imports Python into the-loop's process with the-loop's credentials. This is the boundarycritics[]androuting.graph.hooksalready have; the declaration is the operator's file, and apathresolves against that file's directory, never a checkout — so a session cannot place a module where its own daemon will import it. - Facts → a remote server. The context carries the work item's ref, the actor's login, the harness, the model, the node, the prompt text at
session_spawnand the question text atwaiting_for_input. No token, no secret, no environment variable value is a fact. The prompt text can contain the untrusted comment that triggered the event; it goes to a server the operator chose, over HTTPS unless loopback. - A remote result → the-loop's decisions. Only decision fields are read; only their declared types are accepted; a decision can refuse a start or a launch, reword a prompt or a question, or silence an announcement. It cannot select a graph, approve a gate, choose an edge, name a path, or reach anything the point does not expose.
- Secrets.
tokenEnvnames a variable; the value is read at call time and appears in no log, no event and no context. - An agent inside a session runs
the-loop askwith the daemon's exported config path (issue-393 B6), so the same hooks load in that process — the operator's hooks, from the operator's file, with the session's environment. A session cannot add one.
- Configuration → code execution. A
- Abuse cases (EARS):
- WHEN a repository carries a file at the path a
path:entry names relative to a checkout THEN the system SHALL NOT import it — resolution is against the config file's directory only. - WHEN a remote server returns a result setting a fact (
work_item,loop,harness), an unknown key, or a decision of the wrong type THEN the system SHALL ignore the fact and the unknown key, and SHALL treat the wrong type as that hook's failure — the context the-loop applies SHALL carry no such value. - WHEN a remote server is unreachable, slow past
timeoutSeconds, or answers with an error THEN the system SHALL continue with the context unchanged by it (R6.1), or refuse the operation when the entry isrequiredat aproceedpoint (R6.3) — never hang a dispatch and never raise. - WHEN an
http://URL to a non-loopback host is declared with atokenEnvTHEN loading SHALL fail — a bearer token is not sent in clear over a network. - WHEN
tokenEnvnames a variable that is unset at call time THEN the system SHALL NOT send the request and SHALL record the hook's failure. - WHEN a hook module raises on import or construction THEN a daemon SHALL refuse to start (R3.6) and a one-shot command SHALL run with no hooks and a recorded
hooks.load_failed— no half-loaded chain runs. - WHEN an executor raises inside a point THEN the dispatch, advance or ask SHALL complete (R6.2); the failure is recorded.
- WHEN a declaration names an event type or any name outside the catalog under
onTHEN loading SHALL fail (R1.3).
- WHEN a repository carries a file at the path a
- Accepted, stated risk. A hook may reword the prompt a session boots on and the question an agent asks a human. That is the feature (an enterprise prefixes its own instructions; a policy adds a tag) and it is the operator's code on the operator's machine. The audit trail is the event log's
hooks.decidednaming the hook and the field, and the ticket comment that shows the final question text. A remote server sees the facts listed above; the operator chooses the server and the transport. - Fail closed: a malformed declaration, an unloadable module, an unknown point, a cleartext-token URL — each stops the load and reports. A
requiredhook that cannot answer refuses the operation it was asked about. Nothing degrades to "no hooks" in a daemon.
Out of scope
- A second remote transport (MCP). sherma hosts hooks over MCP too. One transport ships here; the executor abstraction admits another without touching the call sites or the contract (design § Trade-offs).
- Hooks on graph node boundaries. That is
routing.graph.hooks(issue-248); a lifecycle hook does not gate a node and a graph hook does not see a spawn. The docs say which to reach for. - Moving the-loop's existing side effects onto hooks. R7 makes the announcements consult the seam; re-implementing the announcer, the channel publishers or the reactions as shipped hook executors is a later work item once this seam has been used in anger.
- Asynchronous execution, queues, retries. A hook runs in the path it decides; a slow hook is bounded by its timeout. An operator who wants fire-and-forget telemetry writes a hook that returns immediately and posts from its own thread.
- Hot reload of the declaration. Once per process (R3.7), as graph hook modules are.
Open questions
Raised on the ticket as comments and linked here.
- The timeout default (10 s) and the seven points' names are the design's; the owner may want other words or other moments — the catalog is the one place to change.
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109). Append-only and attributed: an approval never silently discards a reviewer's suggestions, and the feedback travels with the document it concerns rather than living in a side-channel tracker.