Requirements: the-loop as a graph of nodes with entry/exit hooks
Phase 1 of 3. Derived from the locked
brainstorm.md. Rewritten from a fresh slate on the owner's simplification direction (PR #110), anchored on that comment and on the original bullets in issue #109.
Introduction
the-loop's PDLC — brainstorm → requirements → design → tasks → implement → review → evidence → complete → learn — exists only as prose. Nothing evaluates it, so steps get skipped or invented. Measured in this repository: of 26 execution logs, 23 sit at needs-review and none reaches complete, while 15 issues carry loop:complete; 15 of 28 requirements.md and 16 of 33 design.md are still status: draft despite shipping.
This work item makes the PDLC an explicit graph, walked by a runtime in the-loop's CLI, built from exactly two concepts:
- a node — a step, with
entryandexithook chains; - a hook — a unit of work with a fixed signature, returning a
HookResultthat decides whether the work item moves.
Everything the-loop needs to do at a step boundary — validate artifacts, lint them, update a label, request a review, notify Slack, update Jira, classify a human's reply — is a hook. One shape, used everywhere.
Risk tier 4 — what that actually means here
This work item is risk tier 4, because gate classification reads human-authored text (attacker-reachable on a public repository) and the-loop gains outbound integrations holding credentials. Two rules in this repository's own .the-loop/harness-config.yaml then apply, and because the jargon is easy to skim past, here is what each one concretely requires:
| Config | Says | In practice, for this work item |
|---|---|---|
autonomy.tiers: {"4": human-approves-pr} | tier 4 may not self-complete | the loop can do everything up to "ready", but a human merges the PR |
security.review.humanSignOffMinTier: 4 | at tier ≥ 4 a named human approves the security review, recorded on the PR | someone writes, in a comment, that they have read the Security design section and accept it — with their name against it |
It does not imply a security team. "Named human sign-off" means the paper trail records who accepted the security analysis, so the-loop cannot self-certify its own threat model. For this repository that person is the owner. The practical deliverable is one comment on the PR of the form "security review read and accepted — @handle, <date>", recorded in the execution log's Security review section.
The tier also raises the bar on this spec itself: the Security considerations section below has to be specific and honest about new attack surface rather than asserting "no new attack surface".
Requirements
Requirement 1 — The graph is declared data, owned by the-loop
User story: As a the-loop maintainer, I want the PDLC declared as data shipped with the CLI, so the process is versioned with the code that runs it and no repository can redefine what executes.
Corrected 2026-07-29 after PR review (@MadaraUchiha-314: "this file should ship with the CLI not with the harness integration which is the plugin"). R1.1 originally said "the installed plugin", which contradicted this very requirement's own rationale — the plugin is the harness integration, and it is not the code that runs the graph; the CLI is, and every hook the graph names is registered in
the_loop.graph.hooks. Shipping it with the plugin meantpip install the-loopy-oneproduced a runtime with no process to run. See the execution log entry for the reproduction.
- WHEN the runtime starts THEN it SHALL load the graph from the installed CLI package (package data, resolved relative to the runtime module — so a wheel, an editable install and a repository checkout all behave identically) and SHALL NOT read a graph from the working repository. 1a. WHEN the CLI is installed from PyPI with no plugin and no repository checkout THEN
the-loop checkSHALL still find its graph. The graph and the hooks it names SHALL be distributed as one unit, because a graph the runtime cannot find is a process nothing can run. - WHEN the graph is loaded THEN it SHALL be validated against a checked-in JSON Schema, and the runtime SHALL refuse to run if validation fails.
- WHEN a node is declared THEN it SHALL require
idand SHALL acceptphase,actor,produces,command,stage,session,entry,exitandmaxAttempts. - IF a repository declares its own graph THEN the system SHALL ignore it with a warning naming this as a future feature.
- IF an edge or a hook reference names something undeclared THEN validation SHALL fail with the offending name.
- WHEN the graph declares a cycle THEN it SHALL be accepted — review→fix→review is a valid transition set.
Requirement 2 — The hook contract
User story: As a the-loop maintainer, I want every hook to share one signature and one output type, so new behaviour is a new hook and never a new subsystem.
- WHEN a hook is invoked THEN it SHALL receive a
HookContextcarrying the work item, the node, the boundary (entry/exit), the repository path, the resolved artifact paths, the bound session, the triggering event, prior results in the chain, and config handles. - WHEN a hook returns THEN it SHALL return a
HookResultwithstatus(pass | block | wait | skip), the hook name, an orderedmessageslist, adatamapping, and aretriableflag. - WHEN a hook returns
blockTHEN the node SHALL NOT advance, and the result'smessagesSHALL be delivered to the harness as its next input. - WHEN a hook returns
waitTHEN the node SHALL park and re-run its exit chain on the next inbound event. - WHEN a hook returns
skipTHEN the chain SHALL continue and the skip SHALL be recorded. - WHEN a hook raises, or times out, THEN the runtime SHALL treat it as
block— never aspass. - WHEN a
HookContextis built THEN it SHALL carry secret handles, never secret values.
Requirement 3 — Chains decide whether a node completes
User story: As an operator, I want "is this step done?" to have one mechanical answer, so the CLI never has to interpret prose.
- WHEN a node is entered THEN its
entryhooks SHALL run in declared order. - WHEN a node's work signals it has finished — a process exit, or a harness stop-hook tick — THEN its
exithooks SHALL run in declared order. - WHEN every exit hook returns
passTHEN the node SHALL be complete and the matching edge taken. - WHEN a hook returns a non-
passstatus THEN the chain SHALL short-circuit at that hook. - WHEN a validating hook finds several unmet requirements THEN it SHALL report all of them in one result, so the agent receives the complete list in a single round.
- WHEN feedback is rendered back to the harness THEN it SHALL be composed of the-loop's own text, hook names and artifact paths — never untrusted payload text.
Requirement 4 — The human gate is a node
User story: As a reviewer, I want my actual review — partial, approving with comments, or requesting changes — to be understood, so the process meets me where I write.
- WHEN a work item reaches a human gate THEN it SHALL enter a node whose
actorishuman, not a hook. - WHEN a gate node is entered THEN its entry hooks SHALL request the review, sync the phase label and notify the configured roles.
- WHEN feedback arrives THEN the gate's exit chain SHALL re-run; WHEN the feedback is not decisive (partial, a question, ambiguous) THEN the chain SHALL return
waitand the gate SHALL remain open. - WHEN feedback is classified
approvedTHEN the work item SHALL advance. - WHEN feedback is classified approved with comments THEN the work item SHALL advance and the review SHALL be appended to a
## Review commentssection of the artifact the gate approved — an approval SHALL NOT silently discard a reviewer's suggestions. (Owner decision: the comments live in the generated document, not in a side-channel follow-up list.) - WHEN feedback is classified changes requested THEN the work item SHALL return to the producing node with the feedback as that node's next input.
- WHEN a gate node declares
session: inheritTHEN it SHALL reuse the harness session of the node that produced the artifacts under review. - WHEN classification is performed THEN it SHALL read only text authored by a user in
routing.authorizedUsers, and its result SHALL be constrained to a closed outcome set. - A classification SHALL NOT satisfy an approval that policy reserves for a human — it only classifies a human response that has actually arrived.
Requirement 5 — The hooks the-loop ships
User story: As an operator, I want the defaults to be ordinary hooks, so what ships and what I could add are the same kind of thing.
- WHEN the-loop ships THEN it SHALL provide at least:
set-phase-label,request-review,notify,log-entry,validate-artifacts,lint-artifacts,verify-tests,classify-feedback,record-feedbackandrecord-decision. - WHEN
validate-artifactsruns THEN it SHALL check existence, front-matter lock state and required sections of the node's declared outputs; WHEN the artifact has passed through a gate THEN a non-empty## Review commentssection SHALL be one of those required sections, so a lost review is a blocking finding rather than a silent omission. - WHEN
record-feedbackruns THEN it SHALL append the review, attributed and dated, to the artifact's## Review commentssection without rewriting earlier entries. - WHEN
lint-artifactsruns THEN it SHALL run the configured markdown linter and verify that every mermaid block parses. - WHEN
set-phase-labelruns THEN it SHALL sync the ticket label for the node'sphase. - WHEN
request-reviewor any other commenting hook posts THEN the comment SHALL carry the-loop's self-authored marker. - WHEN
notifyruns THEN recipients SHALL resolve only throughnotifications.events→ roles →.the-loop/collaborators.yaml.
Requirement 6 — Integrations: two call planes, configurable transport
User story: As an operator, I want to choose how the-loop reaches GitHub, Slack and Jira — and I want the agent left free to reach anything however it likes — so the-loop fits my environment instead of dictating it.
- WHEN the agent reaches an external service from inside its session THEN the-loop SHALL NOT constrain how — CLI, MCP or API are all the harness's and the operator's business. (Owner: "anything that the LLM uses can be through CLI, MCP or API as LLM is free to do whatever it wants.") Everything below governs the-loop's own calls only.
- WHEN the-loop calls an external service THEN the transport SHALL be configurable per integration in the CLI config, supporting at least
apiandcliwhere both are meaningful, andsdkwhere an official one exists. - WHEN
transport: autois configured THEN resolution SHALL follow a documented order — a configured API token first, then an installed CLI binary — and WHEN neither is available the system SHALL fail closed naming both remedies. - WHEN an explicit transport is configured THEN it SHALL be honoured verbatim and SHALL fail rather than silently falling back to another.
- WHEN the-loop notifies Slack THEN the default transport SHALL be the official
slack-sdk, with a dependency-free rawwebhooktransport available as the alternative. - WHEN the-loop calls GitHub THEN both an
apitransport (stdlib HTTP + token) and aclitransport (the existingghpath, inheriting the operator'sgh auth, including enterprise/SSO) SHALL be available;autoSHALL be the default. - WHEN the-loop updates Jira THEN an
apitransport SHALL be available, with aclitransport supported for parity. - WHEN a transport provider is registered THEN it SHALL declare the operations it implements.
- WHEN the runtime loads THEN it SHALL verify that every operation the configured graph's hooks require is implemented by the configured transport, and SHALL fail at load time — naming the operation, the target and how to fix it — rather than failing mid-traversal.
- WHEN a transport is swapped THEN the
HookResulta hook returns SHALL be unchanged — transport SHALL affect how a side effect is performed, never whether a node advances. - WHEN an integration is unavailable except through MCP THEN the-loop SHALL perform the call by delegating to the harness with schema-constrained output, rather than implementing the MCP protocol itself.
- WHEN an integration call fails THEN the hook SHALL record the failure and the runtime SHALL continue unless that hook is declared blocking — a channel outage SHALL NOT wedge the graph.
- WHEN any integration is configured THEN its credentials SHALL come from environment or a secret store, never from the repository, graph state or logs.
- WHEN the
clitransport is used for GitHub THEN it SHALL reuse the existingghcode paths (announce,comments,control,reactions,poller/github) rather than replacing them — configurable transport turns the migration into an addition.
Requirement 6a — One integration pattern across both config files
User story: As an operator, I want transport declared once, so I am not repeating the same setting in three places and wondering which one wins.
- WHEN transport or credentials are configured THEN they SHALL be declared once, in
cli-config.yamlunderintegrations, and SHALL NOT be redeclared per feature. - WHEN a per-repo config names an integration THEN it SHALL reference the provider by name and SHALL NOT declare how to reach it — intent in
harness-config.yaml, recipients incollaborators.yaml, transport and credentials incli-config.yaml. - WHEN the configs are reconciled THEN the split established by decision-032 SHALL be preserved: per-repo intent in the harness config, daemon mechanics in the CLI config.
- This SHALL be a breaking change, not a deprecation (owner decision: "Let's make breaking changes. /upgrade should be able to handle it."). The legacy per-feature keys (today
ghBinaryundercontrol,reactionsandannounce) SHALL be removed, so there is exactly one way to declare transport. - WHEN the config schema changes THEN its
versionSHALL be bumped, so an out-of-date config is detected by version rather than by sniffing for keys. - IF a config still carries a removed key, or is below the required version, THEN the runtime SHALL refuse to start, naming the offending key, its replacement, and
/the-loop:upgrade-the-loop— it SHALL NOT silently ignore a value the operator set. - WHEN
/the-loop:upgrade-the-loopruns against an out-of-date config THEN it SHALL perform the migration as a deterministic key move, SHALL be idempotent, SHALL support--dry-run, and SHALL report every key it moved rather than rewriting the file silently. - WHEN the migration is implemented THEN it SHALL be covered by tests — an old-config fixture migrating to the expected new config, and a test asserting the runtime refuses an un-migrated config.
Requirement 6b — The graph runtime
User story: As a maintainer, I want to know exactly what compiles and runs the graph, so the orchestrator is reviewable rather than magical.
- WHEN the graph is loaded THEN parsing, validation, hook-name resolution, edge indexing and freezing SHALL all occur once at load, so every structural failure is a startup failure naming the offending element.
- WHEN a hook is registered THEN it SHALL use the same registry pattern the CLI already uses for sub-commands (
Command/@register/_REGISTRYincommands/base.py). - WHEN the runtime advances a work item THEN it SHALL do so with a plain synchronous state machine over stdlib data structures — no workflow engine, scheduler, task queue, database or async runtime.
- WHEN graph state is written THEN it SHALL be persisted before the side effect that depends on it.
- WHEN
the-loop run, the daemon, orthe-loop checkevaluates a node THEN all three SHALL call the same chain-execution code, so CI runs the runtime rather than a reimplementation of it. - WHEN the runtime validates the shipped graph THEN it SHALL require no new runtime dependency; full JSON-Schema validation SHALL run in the-loop's own CI, where
jsonschemais already a development dependency.
Requirement 7 — Sessions
User story: As an operator watching a tmux session, I want to take over at any moment, so automation never costs me the ability to intervene.
- WHEN a work node runs THEN it SHALL run through the configured runner, including the resident tmux session, so a human can attach and take over.
- WHEN a human takes over a session THEN the exit chain SHALL still evaluate against the artifacts that session produces.
- WHEN a node declares
session: inheritTHEN it SHALL bind the previous node's session. - IF an inherited session has died THEN the system SHALL fall back to a fresh session seeded with the work item's artifacts (
requirements.md,design.md,execution-log.md) as context, recording the fallback — it SHALL NOT block. (Owner decision: those artifacts are enough to restart the session.) - WHEN a model call is required by a hook THEN it SHALL run as a separate short-lived headless process at the cheapest declared tier, and SHALL NOT be injected into the resident session.
Requirement 8 — Graph state, recovery and observability
- WHEN a work item is walked THEN the system SHALL maintain a checked-in
graph-state.jsonholding the current node, per-node attempts and outcomes, recorded hook results and decisions, the bound session and any parked reason. - WHEN state is written THEN it SHALL be persisted before any dependent side effect.
- WHEN state is missing or unparseable THEN the system SHALL reconstruct by re-running the validating hooks against the artifacts, warn, and SHALL NOT delete the file.
- WHEN
the-loop check --recomputeruns THEN it SHALL ignore graph state and derive completion from artifacts alone; CI SHALL use it. - WHEN the same blocking message recurs on two consecutive attempts, or attempts reach
maxAttempts, THEN the system SHALL escalate to a human and stop advancing. - WHEN a harness session dies mid-node THEN the system SHALL respawn/resume and re-enter the same node.
- WHEN any node is entered or exited, any hook returns non-
pass, or any edge is taken, THEN a JSONL event-log record SHALL be emitted. - WHEN
the-loop checkruns THEN it SHALL make no network call and no model call.
Requirement 9 — Backwards compatibility
- WHEN a repository has never seen the graph THEN the system SHALL work without requiring any file to be added to it.
- WHEN a node declares a
phaseTHEN its label SHALL be kept in sync as today; nodes without one SHALL NOT create new labels. - WHEN
workflow.phasesis present THEN the shipped graph SHALL be authoritative and the phase list treated as derived, warning on divergence.
Requirement 10 — The escape hatch: forcing a transition
User story: As the authorized operator with shell access, I want to force a work item from one node to another regardless of its gates, so a wrong gate, a stuck classification or an unforeseen situation never leaves me unable to move my own work item.
- WHEN an operator runs
the-loop graph force --work-item <ref> --to <node>THEN the system SHALL move the work item'scurrentNodeto the named node regardless of whether the current node's exit chain passes. - WHEN the target node is not declared in the graph THEN the system SHALL refuse and list the valid node ids — the escape hatch bypasses gates, never the graph's own vocabulary.
- WHEN a force is performed THEN
--reasonSHALL be required; an unexplained override SHALL be refused. - A force SHALL move the pointer, never forge a verdict. The forced transition SHALL be recorded as
forcedin graph state, and the bypassed node's gate SHALL remain recorded as whatever it actually evaluated to.the-loop check --recomputeSHALL therefore still report that gate as unmet, so CI and any reviewer see a forced transition for what it is. - WHEN a force is performed THEN it SHALL be recorded in four places: graph state (with actor, timestamp, from, to, reason), the execution log, the JSONL event log, and a marked comment on the work item — an escape hatch that leaves no trace is how determinism dies quietly.
- WHEN a force is attempted THEN authorization SHALL be shell access to the machine running the CLI, not a ticket comment keyword. Forcing SHALL NOT be exposed as a comment command, because comments are attacker-reachable on a public repository while shell access is not.
- WHEN an operator needs to re-run a completed node THEN the same command SHALL move the work item backwards, re-entering that node and re-running its entry chain.
- WHEN a force targets a node the graph cannot reach from the current one THEN the system SHALL still perform it, but SHALL warn that the transition is not a declared edge — the operator is deliberately outside the model and should know it.
- WHEN a force would bypass a
requiredgate (security review, a mandated human approval) THEN the system SHALL perform it, warn explicitly which guarantee was bypassed, and record that in the ticket comment — a human with shell access can already edit artifacts and push, so refusing here would be theatre; making it loud and permanent is the real control.
Non-functional requirements
- Dependencies: one, and it is free. Edges route on hook outcomes only — no expression language (owner decision: "Remove CEL") — and GitHub/Jira HTTP uses the standard library. The single addition is the official
slack-sdk, which declares zero required runtime dependencies of its own, so the-loop's installed footprint grows by one package and no transitive tree. - Both harnesses. Every requirement holds for Claude Code and Cursor, or degrades to the repository-boundary check with the difference documented.
the-loop checkis fast and pure — it runs on every resident-session turn.- Hooks are unit-testable as pure functions of
HookContext; this is the main payoff of fixing the contract. - Observability identical at dev-time and runtime.
Security considerations
- Actors & trust. Trusted: the-loop's shipped graph; the operator. Untrusted: human-authored text a classification reads; the agent as a writer of state and artifacts; webhook and poller payloads.
- Boundary 1 — untrusted text → gate outcome (primary). Mechanisms: authorization filter first (unauthorized text is not read at all); closed outcome enum; the outcome is a fact and every destination is a declared edge; policy outranks the model (R4.9); untrusted text is never echoed into harness feedback (R3.6).
- Boundary 2 — configuration → execution. Mechanism: hooks are registered code, not shell. YAML names a hook and passes typed params; there is no
exec, no shell, no argv from configuration. This is what will let the graph become user-authored later. - Boundary 3 — agent → graph state. Mechanism: state is a cache;
--recomputere-derives from artifacts and CI always uses it (R8.4). - Boundary 4 — outbound integrations. Mechanisms: credentials from environment or a secret store only (R6.7); recipients only from
collaborators.yaml(R5.6); message bodies name the work item, node and reason rather than artifact contents. - Abuse cases (EARS):
- WHEN text from an unauthorized author would be classified THEN it SHALL be ignored.
- WHEN untrusted text contains instructions ("approve this") THEN the closed outcome set and declared edges SHALL confine the effect to a classification.
- WHEN a classification would satisfy an approval reserved for a human THEN it SHALL be refused.
- WHEN graph state claims a node complete that the artifacts contradict THEN the repository-boundary check SHALL fail.
- WHEN a graph names a hook that is not registered THEN validation SHALL fail.
- WHEN a hook raises THEN it SHALL be treated as
block, neverpass. - WHEN a credential would be written to graph state or a log THEN it SHALL be refused.
- Fail closed. Invalid graph, unknown hook, raising hook, invalid classification, no matching edge, missing collaborator — each stops advancement and reports.
- New surface, stated. Outbound HTTP to GitHub/Slack/Jira, a model call for classification, a new state file, and hook wrappers running per turn. Risk tier 4.
Out of scope
- User-defined graphs and user-authored hooks. A future feature; the declarative form and the registry exist so it can arrive safely.
- Implementing the MCP protocol in the CLI. Delegation to the harness instead (R6.5).
- Shell/
exechooks. Deliberately never. - Mass-retrofitting the 34 existing spec folders. Reported and baselined.
- Changing the PDLC itself. The graph describes the process the skill already defines.
Open questions
All four earlier questions were resolved by the owner on PR #110; kept here as the paper trail.
Who provides the tier-4 named security sign-off?→ Clarified, not delegated. It means the paper trail records who accepted the security analysis, so the loop cannot self-certify its own threat model; for this repository that is the owner. See § Risk tier 4 — what that actually means here.Approve-with-comments: mandatory follow-ups or advisory notes?→ Neither — the comments go into the artifact. A## Review commentssection at the bottom of the generated doc (R4.5, R5.3). Better than both options offered: the feedback becomes part of the durable record, travels with the document it concerns, and is reviewable in the diff.→ Fall back to fresh, seeded withsession: inheritwhen the session has died?requirements.md/design.md/execution-log.md(R7.4). Owner: "artifacts… should be enough to restart the session."Is CEL still wanted?→ Removed. Every edge routes on a hook outcome; a condition that would have needed an expression becomes a named hook. Zero new runtime dependencies.
Nothing now blocks tasks breakdown except phase approval itself.