Survey: the architecture of the-loop (CLI)
Answers issue #354 — five questions about how the-loop is put together, traced through the code at v15.0.0. Reader: the owner, who knows the process and wants to see how the CLI enforces it. Every claim names the module it comes from so it can be checked against the tree.
The short answers.
- the-loop is a process graph (
cli/the_loop/graph/) executed by a daemon (webhook/,poller/,dispatcher.py) that spawns and steers harness sessions (Claude Code in tmux) and a plugin (hooks/,commands/,skills/) the session runs under. One process hosts all of it: the control-plane service (api/). the-loop startis a control command consumed by the daemon: it records an authorized request, checks out a workspace, spawns a tmux session, enters the graph atphase-selection, and posts the phase checklist.the-loop executeis a gate answer: the daemon hands the comment to thephase-selectionexit hook, which freezes the selection into graph state and moves the pointer to the first selected node.- A work item cannot reach
designbeforerequirements-approvalbecause the only edge intodesignleavesrequirements-approval, and that node's exit hook returnswaituntil an authorized human approves on the ticket. That is code:Runtime.advancetakes no edge unless the exit chain passes,the-loop check --recomputere-derives the current node from the artifacts alone, the harness Stop hook blocks the session on ablock, and CI runs the same check. What is not prevented: the session writingdesign.mdearly. It gets no pointer, no label and no gate for it. - The daemon pushes; the agent reports. The harness does not ask "what next": the daemon pastes the next assignment into the session when the pointer moves, and the agent reports completion with
the-loop graph complete <id>, whose JSON reply names the node it now stands at. The Stop hook is the one pull: at every turn end it runsthe-loop checkand refuses to let the session stop on an unmet node. - Diagrams: one component diagram (§1) and three sequence diagrams (§2, §3, §4).
1. How the-loop is architected
graph TB
subgraph HARNESS["Harness session (claude / cursor) — one per work item, in tmux"]
AG["agent"]
PL["the-loop plugin<br/>commands/*.md · skills/the-loop · hooks/hooks.json"]
SH["Stop hook<br/>hooks/the-loop-gate.py"]
AG --- PL
AG --- SH
end
subgraph SVC["the-loop service — one process (api/lifespan.py)"]
subgraph ING["ingress"]
WH["gh-webhook receiver<br/>webhook/server.py · daemon.py"]
PO["poller<br/>poller/poller.py · github.py"]
end
RT["router<br/>webhook/router.py"]
DI["dispatcher<br/>webhook/dispatcher.py"]
CT["control keywords<br/>control.py"]
GL["GraphLink<br/>graphlink.py"]
subgraph GR["graph runtime — graph/"]
RU["Runtime<br/>runtime.py · chain.py"]
HK["hooks<br/>graph/hooks/*.py"]
GD["pdlc-*-loop.yaml<br/>(shipped, read-only)"]
end
TM["TmuxRunner + harness adapters<br/>runner.py · harness/*.py"]
RG[("session registry<br/>sessions/registry.py")]
API["HTTP + MCP API<br/>api/routes.py · api/mcp.py"]
CORE["core facade<br/>core/graphs.py · core/sessions.py"]
end
CLI["the-loop CLI<br/>commands/graph_cmd.py (check · graph)"]
subgraph REPO["the repository checkout"]
ST[("docs/specs/ID/graph-state.json")]
AR[("docs/specs/ID/*.md<br/>requirements · design · …")]
end
GH["GitHub<br/>issue · PR · labels · comments"]
PT[("state.root/portable/ITEM.json<br/>control · frozen graph")]
GH -->|"webhook (HMAC)"| WH
GH -->|"gh api"| PO
WH --> RT --> DI
PO --> DI
DI --> CT
DI --> GL --> RU
RU --> HK
GD --> RU
RU <--> ST
HK --> AR
HK -->|"labels · comments"| GH
DI --> TM -->|"spawn / paste prompt"| AG
DI <--> RG
DI --> PT
AG -->|"writes"| AR
AG -->|"the-loop graph complete"| CLI
SH -->|"the-loop check --recompute"| CLI
CLI -->|"HTTP (client/routing.py)"| API --> CORE --> RU
AG -->|"comments · PRs (gh)"| GHThree layers, one authority. The plugin tells the agent how to work (prose). The daemon decides when a session exists and what lands in it (code). The graph runtime decides where a work item is and whether it may move (code). The graph is the authority: the daemon and the CLI both call the same Runtime, and nothing else moves the pointer.
| Component | Where | Responsibility |
|---|---|---|
| Process graphs | graph/pdlc-work-item-loop.yaml and four siblings | Nodes, actor, entry/exit hook chains, edges keyed on outcome. Shipped with the plugin; a repository cannot override them (model.py:_warn_on_repo_graph). |
| Graph runtime | graph/runtime.py, graph/chain.py, graph/model.py | start, complete, advance, status; compile-time validation of the graph; short-circuiting chain execution. No scheduler, no queue, no async. |
| Graph hooks | graph/hooks/*.py | The checks (validate-artifacts, lint-artifacts, enforces-boundaries-from, classify-feedback, classify-phase-selection) and the side effects (set-phase-label, log-entry, post-phase-selection, request-review, lock-artifacts, deliver-assignment, notify). |
| Graph state | graph/state.py → docs/specs/<id>/graph-state.json | Pointer, per-node attempts, decisions, declared skips, session binding. Checked in. A cache, never an authority (the module docstring). |
| Ingress | webhook/server.py, webhook/daemon.py, poller/ | Two ways in, one event shape (RoutedEvent). |
| Router | webhook/router.py | Event-type filter, dedup, repository bound, work-item extraction, self-authored-comment drop, authorizedUsers check. |
| Dispatcher | webhook/dispatcher.py | Control keywords, arming, workspace, spawn/resume, per-work-item worker threads, prompt rendering, consult-the-gate-first delivery. |
| Control | control.py, <state.root>/portable/ | Keyword table (start, stop, pause, resume, execute, contribute, do, review, …), the durable control record, the frozen graph. |
| GraphLink | graphlink.py | The daemon's adapter to the runtime: builds it in the session's checkout, injects the assignment sink, renders $graph_context into prompts, holds the state lock. |
| Sessions | runner.py, harness/*.py, sessions/registry.py | tmux spawn / paste / kill; harness argv (claude --session-id …, --resume); one registry record per work item. |
| CLI + service | commands/graph_cmd.py, client/routing.py, api/, core/ | the-loop check and the-loop graph … are HTTP calls to the service, which auto-starts if permitted (client.connect). The same verbs are MCP tools. |
| Plugin | hooks/hooks.json, hooks/the-loop-gate.py, commands/*.md, skills/the-loop/ | What the session runs under: the SessionStart reminder, the Stop gate, the slash commands, the operating model. |
The capability docs carry the current behaviour of each part: process-graph, cli, webhook-triggers, interactive-sessions, control-plane. This report links rather than repeats.
2. The exact flow: the-loop start, then the-loop execute
Both are comments on the ticket. The daemon reads them; the agent never sees start and sees execute only after the gate has already classified it.
sequenceDiagram
autonumber
actor H as authorized human
participant GH as GitHub
participant IN as ingress (webhook / poller)
participant RT as router
participant DI as dispatcher
participant WS as workspace + tmux
participant GL as GraphLink
participant RU as Runtime
participant AG as agent session
H->>GH: comment "the-loop start" on issue #N
GH->>IN: issue_comment (HMAC-verified) / polled
IN->>RT: route(event)
RT->>RT: dedup · repo bound · drop self-authored · actor ∈ authorizedUsers
RT->>DI: handle(RoutedEvent)
DI->>DI: parse_command → "start" (control.py)
DI->>DI: named actor ∈ authorizedUsers (second check)
DI->>DI: armed? (label / spawnOnUnmatched) → record control "start" (portable/)
DI->>WS: prepare workspace (clone + git worktree)
DI->>WS: tmux new-session … claude --session-id UUID "spawn prompt"
WS-->>AG: session starts · prompt says "/the-loop:work-on ITEM"
DI->>GL: on_spawn(item, cwd, session_id)
GL->>RU: start(item)
RU->>RU: state.enter("phase-selection") · state.save()
RU->>GH: set-phase-label loop:phase-selection · post-phase-selection (checklist)
RU->>AG: deliver-assignment: "HUMAN gate — do not claim it"
RU-->>GL: advance(event = the start comment) → wait
Note over H,GH: the checklist comment is now on the ticket
H->>GH: tick boxes, reply "the-loop execute"
GH->>IN: issue_comment
IN->>RT: route → authorized
RT->>DI: handle
DI->>DI: parse_command → "execute" ∈ GRAPH_COMMANDS (recorded, not consumed)
DI->>GL: context(item) → at_human_gate
GL->>RU: advance(item, event={comments})
RU->>RU: exit chain: classify-phase-selection
RU->>GH: read the checklist's tick state (or the reply's own list)
RU->>RU: skips / optIns / surface / sessionPerPr → state.decisions · frozen graph → portable/
RU->>GH: confirmation comment
RU->>RU: state.exit(phase-selection, "selected") · route around declared skips
RU->>RU: state.enter("requirements-definition") · state.save()
RU->>GH: set-phase-label loop:requirements-definition · log-entry
RU->>AG: deliver-assignment: "produce requirements.md … when done: the-loop graph complete ID"
DI->>AG: event prompt with $graph_context (verdict: selected)the-loop start, step by step
- Ingress. The webhook receiver verifies the HMAC (
webhook/server.py), thenwebhook/daemon.pycallsRouter.routeandDispatcher.handle. The poller (poller/github.py) synthesizes the sameRoutedEventfromghlistings, so the rest of the path is identical. Without a webhook the poll cycle is the trigger. - Routing (
webhook/router.py:Router.route). In order: event-type filter, delivery-id dedup, repository bound (repositoriesin the CLI config), work-item extraction (issue number,issue-<n>branch convention, closing keywords), drop of any comment carrying the-loop's own<!-- the-loop:agent-comment -->marker, and theauthorizedUserscheck. An unauthorized actor is dropped here. - Keyword parse (
control.py:parse_command). A whole-token, case-insensitive match over the configured keyword table, gated onrouting.control.enabled. The result is one of a fixed set of constants, never body text; two keywords in one comment isambiguousand nothing runs. - Second authorization (
dispatcher.py:Dispatcher.handle). A control command needs a named actor inauthorizedUsers, checked again, because this comment is about to command the daemon.startis then consumed: it is applied and never forwarded to a session. - Arming (
dispatcher.py:_apply_control,_spawn_refusal).startspawns only if the item is armed — the auto-execute label, orspawnOnUnmatched: always. A refusedstartrecords nothing, on purpose: a stray comment on a backlog item must not leave a standing request. An accepted one writes thecontrolsection of<state.root>/portable/<item>.json(control.py:ControlStore.record), which is what survives a daemon restart. - Spawn (
dispatcher.py:_spawn_for, on a per-work-item worker thread). Open any channel threads (Slack); prepare the workspace (workspace.py: clone underrouting.workspace.root, one git worktree per work item); read the graph context; render the spawn prompt (DEFAULT_SPAWN_TEMPLATE, or the template file) with$graph_contextand$interaction_directive; pre-trust the checkout and enable the plugin (harness/claude_code.py:prepare_environment);tmux new-session -d -s loop-<slug> -- claude --session-id <uuid> … "<prompt>"(runner.py:TmuxRunner.spawn); register the session (sessions/registry.py); announce it. - Graph entry (
graphlink.py:GraphLink.on_spawn→runtime.py:Runtime.start). Guarded by: graph enabled, a spec id derivable from the ref, a start recorded, the checkout belonging to this repository,docs/specs/<id>/inside it.startis idempotent: a work item with a pointer returnsNone, so a redelivered spawn cannot rewind it. Otherwise it entersphase-selection, savesgraph-state.jsonbefore any side effect, and runs the node's entry chain:set-phase-label(loop:phase-selection),log-entry(a checkpoint inexecution-log.md),post-phase-selection(the checklist comment, idempotent via its own marker),deliver-assignment(pasted into tmux: "this node is a HUMAN gate — do not claim it"). - The start comment is offered to the gate once (
on_spawncallsadvancewith the comment attached), because the control path consumed it and no later event will carry it. Forstartthe outcome iswait; forcontribute, whose arming comment carries the goal, this is how the first gate is answered without a second command.
Slack and the CLI differ only at the front. the-loop sessions start applies locally and posts the same keyword to the ticket with the self-authored marker so no ingress re-executes it (core/sessions.py:control_session). A Slack /the-loop start relays an unmarked comment to the ticket (channels/github.py:relay_body) and the GitHub ingress above executes it — through the ledger, never around it.
the-loop execute, step by step
- Same ingress, same routing, same parse.
executeis inGRAPH_COMMANDS, so the dispatcher only records it (_record_graph_command, event log) and falls through: the comment must reach the gate, not be consumed by the daemon. - Consult the gate first (
dispatcher.py:_dispatch_one). The dispatcher reads the graph context; because the pointer is at a human node (at_human_gate), it callsGraphLink.on_event→Runtime.advance(event={"comments": …})before rendering a prompt, so approval and reaction cannot race. - Classify (
graph/hooks/selection.py:classify-phase-selection). Skips if the decision is already recorded; otherwise finds authorized, non-self-authored comments containing the execute keyword (routing.control.keywords.execute, defaultthe-loop execute); takes the latest; uses a checklist inside the reply if there is one, else re-reads the live tick state of the-loop's own checklist comment. Unticked skippable phases become skips; an unticked protected phase is refused out loud; an opt-in phase runs only if ticked. The same reply answers two non-phase rows: the outer-loop surface (work item or PR) and sessions-per-PR. No authorized comment meanswait. - Freeze (
runtime.py:_record_selected_skips). The skips, opt-ins, surface and PR-session mode go intostate.decisionswith provenance (who, via what, when), and the frozen graph — every node marked selected/skipped/opt-in — is written both into graph state and to the portable control record (control.py:record_frozen_graph). From here the selection is a recorded fact; editing the checklist comment changes nothing. - Move (
runtime.py:advance).state.exit(phase-selection, "selected"), take theon: selectededge tobrainstorming, then_route_skipswalks around every declared-skipped successor (their hooks never run, their record saysskipped) until it lands on the first selected node —requirements-definitionfor a typical item.state.enter,state.save, then that node's entry chain: label, log entry, anddeliver-assignment— now an agent assignment:produce: requirements.md,work it with: /the-loop:new-requirement <id>,when done: the-loop graph complete <id>. - Deliver. The dispatcher renders the event prompt with the refreshed
$graph_contextand the gate's verdict, and pastes it into the tmux session (runner.py:TmuxRunner.deliver: tempfile →load-buffer→ bracketedpaste-buffer→ submit). If the session died, it is resumed withclaude --resume <id>or a fresh one is spawned.
3. The main question: what keeps design behind requirements
The answer is the graph's topology plus a runtime that only follows edges. The edges into design are requirements-approval → design on approved, approved-with-comments or skipped, and design-approval → design on changes-requested. There is no edge from requirements-definition to design. Runtime.advance computes the outcome of the current node's exit chain and takes the edge declared for that outcome; on wait or block it returns without moving, and on an outcome with no edge it parks and escalates rather than guessing.
sequenceDiagram
autonumber
participant AG as agent session
participant SH as Stop hook (the-loop-gate.py)
participant CLI as the-loop CLI → service
participant RU as Runtime
participant FS as docs/specs/ID/
participant GH as GitHub
actor H as authorized human
Note over AG: pointer at requirements-definition
AG->>FS: write requirements.md
AG->>CLI: the-loop graph complete ID
CLI->>RU: complete(item)
RU->>RU: claim names the current node? (else refused / no-op)
RU->>FS: exit chain: validate-artifacts (sections) · lint-artifacts
alt sections missing
RU-->>AG: {status: block, messages: [...]} — pointer unchanged
AG->>AG: turn ends
SH->>CLI: the-loop check ID --recompute
CLI-->>SH: currentNode=requirements-definition, status=block
SH-->>AG: exit 2 — "this step is not complete" (attempt n/3)
else artifact complete
RU->>RU: exit(pass) → enter requirements-approval · save
RU->>GH: request-review comment · notify
RU-->>AG: {moved: true, currentNode: requirements-approval}
AG->>CLI: the-loop graph complete ID (a premature claim)
CLI->>RU: complete → exit chain: classify-feedback
RU->>RU: no event on the CLI path → no authorized comments → wait
RU-->>AG: {status: wait} — pointer unchanged
AG->>AG: turn ends · Stop hook sees wait, not block — lets it stop
H->>GH: "approved"
GH->>RU: (via ingress → dispatcher → GraphLink) advance(event={comments})
RU->>RU: classify-feedback: author ∈ authorizedUsers, not self-authored → approved
RU->>FS: lock-artifacts: status: approved + approver into requirements.md
RU->>RU: exit(approved) → enter design · save · set-phase-label loop:design
RU->>AG: deliver-assignment: "produce design.md …"
endThe guardrails, one by one
| # | Guardrail | Kind | Where |
|---|---|---|---|
| 1 | The only edges into design leave requirements-approval (or come back from design-approval). No requirements-definition → design edge exists. | programmatic | graph/pdlc-work-item-loop.yaml edges:; model.py:Graph.next_node |
| 2 | advance moves only on a passing exit chain; wait parks, block counts an attempt and escalates on repeat or at max_attempts; an outcome with no edge parks and escalates. | programmatic | runtime.py:Runtime.advance |
| 3 | A chain short-circuits at the first hook that is not pass/skip; a hook that raises or times out is a block, never a pass. | programmatic | graph/chain.py:run_chain |
| 4 | requirements-definition exits only when exactly one of requirements.md/bugfix.md exists (two present blocks) with non-empty ## Requirements and ## Security considerations; every unmet finding is reported in one result. A gate that resolves no artifact fails closed. | programmatic | graph/hooks/artifacts.py:validate_artifacts |
| 5 | requirements-approval exits only when classify-feedback sees a comment whose author is in routing.authorizedUsers and which does not carry the-loop's own marker; an empty allowlist denies everyone; indecisive text is wait, never a guessed approval. The verdict is confined to three outcomes. | programmatic | graph/hooks/feedback.py:_authorized_comments, _classify; authz.py:is_authorized |
| 6 | The comments a gate reads come only from the daemon's ingress (HookContext.event). the-loop graph complete and graph advance accept no --event, so a session claiming at a human gate gets wait. | programmatic | graphlink.py:comments_from; commands/graph_cmd.py |
| 7 | status: approved is written only by lock-artifacts, consuming classify-feedback's verdict from the same chain run; it re-reads the file and blocks if the splice did not land. | programmatic | graph/hooks/feedback.py:lock_artifacts |
| 8 | A completion claim is a claim, not a verdict: it records who claimed and runs the real exit chain. A claim naming a node that is not current is refused; one behind the pointer is a no-op. Load→evaluate→save runs under graph-state.lock. | programmatic | runtime.py:Runtime.complete, _complete_locked; graph/state.py:state_lock |
| 9 | the-loop check --recompute ignores the stored pointer and reports the first node whose exit chain is unmet, from the artifacts alone. A hand-edited graph-state.json saying design reads back as requirements-definition or requirements-approval. | programmatic | runtime.py:Runtime.status(recompute=True), _first_unmet |
| 10 | The harness Stop hook runs that recompute at every turn end and, on a block at the current node, refuses the stop (Claude Code: exit 2, stderr back to the model; Cursor: followup_message), up to three attempts. | programmatic | hooks/hooks.json, hooks/the-loop-gate.py, .cursor/hooks.json |
| 11 | CI runs the-loop check <id> --recompute --fail-on block for every docs/specs/<id>/ a PR touches, and fails closed if it cannot compute the diff. | programmatic | .github/workflows/the-loop-gate.yml |
| 12 | Compile-time validation: required + skippable refused, a skippable node without an on: skipped edge refused, a skip set naming a non-skippable or opt-in node refused. phase-selection is required: true, so no declaration routes around choosing. | programmatic | graph/model.py:compile_graph |
| 13 | Declared skips are filtered through the compiled graph on every read, so a hand-written skip on a protected node is inert and check says so on that node; graph skip refuses a node already entered and requires --reason. | programmatic | runtime.py:declared_skips, invalid_skips, declare_skips |
| 14 | graph force --to <node> moves the pointer but never forges a verdict: --reason mandatory, warnings for an undeclared edge or a bypassed required node, state.forced recorded, a graph.forced event, and an audit comment on the ticket. check --recompute still reports the real result. | programmatic (audited hatch) | runtime.py:force, _announce_force |
| 15 | design's own exit: design.md with Architecture, Security design, Testing strategy, and enforces-boundaries-from blocking when a trust boundary/abuse case marker in the requirements is unanswered. This links content, not approval. | programmatic | graph/hooks/artifacts.py:enforces_boundaries_from |
| 16 | "When a node's work is done, run the-loop graph complete"; "never set status: approved yourself"; "never write a downstream artifact against an unapproved upstream". | prose | skills/the-loop/SKILL.md, reference/workflow.md, commands/work-on.md |
| 17 | The SessionStart reminder ("follow the the-loop skill") and the $graph_context block in every prompt, which names the current node and the claim command. | prose | hooks/hooks.json; graphlink.py:render_graph_context |
| 18 | The deterministic keyword classifier in _classify is the floor; the harness may classify with a model. The output is confined to three outcomes (programmatic); the judgement on free text is heuristic. | mixed | graph/hooks/feedback.py:_classify |
What is not prevented, honestly
- Writing
design.mdearly is not blocked. Nothing stops the agent creating any file at any time. What it cannot get is the pointer, theloop:designlabel (set-phase-labelruns only on node entry) or a passingdesigngate beforerequirements-approvalhas passed. The prose rule (row 16) is what asks it not to. graph-state.jsonhas no checksum or signature. It is plain JSON with an atomic write. The design compensates rather than prevents: every skip/opt-in entry is re-filtered through the compiled graph on read; the frozen graph is also in the portable record outside the checkout; the file is checked in and shows in the PR diff; and both the Stop hook and CI use--recompute, which does not trust it.- The Stop hook ignores
wait(a human gate), by design, so a session may end its turn parked atrequirements-approvalwithout being spun against an absent human. - The Stop hook is a no-op when
THE_LOOP_WORK_ITEMis unset orthe-loopis not onPATH, and it gives up after three attempts. An unrelated session is never blocked; a session outside the daemon's spawn has no gate unless the variable is set. graph forceis reachable from the agent's shell. It is bounded by audit (reason, warnings, event, ticket comment), not by authorization.- The graph gate is not a pre-commit or pre-push hook.
.pre-commit-config.yamlwires ruff, pyright, pytest, markdownlint and config validation. Outside the session the enforcement point is the CI workflow.
4. Does the harness come back to ask what to do next?
No. The daemon pushes assignments; the agent reports completions. There is no graph next or graph context CLI verb (the subcommands are show, status, advance, complete, force, skip, hooks, run). The three touchpoints between a session and the CLI are:
| Touchpoint | Direction | What crosses |
|---|---|---|
| Assignment | daemon → session | On every node entry, deliver-assignment renders the node in the graph's own vocabulary — node id, produce:, the /the-loop:<command> to use, the surface to iterate on, and the exact claim command — and the dispatcher pastes it into the bound tmux session. Every event prompt also carries $graph_context, re-rendered from state. |
| Claim | session → CLI → service → Runtime | the-loop graph complete <id> [--pr N] runs the current node's exit chain and, if it passes, takes the edge and runs the next node's entry chain. The JSON reply — {node, status, outcome, moved, currentNode, messages} — is the-loop's reply with the next step on this path. |
| Gate at turn end | Stop hook → CLI | the-loop check <id> --format json --recompute: a read-only report of every node's verdict and the current node. "Is what I stand on satisfied?", not "what is next?". |
sequenceDiagram
autonumber
participant AG as agent session (tmux)
participant SH as Stop hook
participant CLI as the-loop CLI
participant SVC as service (api/) → core → Runtime
participant DI as dispatcher
participant GH as GitHub
Note over AG: assignment already pasted: "produce design.md … when done: the-loop graph complete ID"
AG->>AG: /the-loop:create-design ID — write design.md, commit, push
AG->>CLI: the-loop graph complete ID
CLI->>SVC: POST (client/routing.py — never a silent local fallback)
SVC->>SVC: complete: exit chain of design → pass · enter design-critic-review (opt-in? else routed around) → test-planning
SVC->>GH: set-phase-label loop:test-planning · log-entry
SVC->>SVC: deliver-assignment: skipped on the CLI path ("the claim envelope carries this")
SVC-->>CLI: {"moved": true, "currentNode": "test-planning", "messages": ["advanced to test-planning"]}
CLI-->>AG: the envelope (stdout, exit 0)
AG->>AG: reads currentNode → /the-loop:create-testing-plan ID
AG->>AG: turn ends
SH->>CLI: the-loop check ID --recompute
CLI-->>SH: currentNode=test-planning, status=block (testing-plan.md missing)
SH-->>AG: exit 2 — keep working (attempt 1/3)
AG->>AG: writes testing-plan.md … the-loop graph complete → design-approval (wait)
AG->>AG: turn ends · check says wait → stop allowed
GH->>DI: human approves (webhook)
DI->>SVC: GraphLink.on_event → advance(event) → approved → tasks-breakdown
DI->>AG: paste: deliver-assignment + event prompt ($graph_context, verdict: approved)Why the assignment is not delivered twice. deliver-assignment needs a delivery sink (assignmentDeliver) that only the daemon's GraphLink injects. On the CLI path (core/graphs.py builds the runtime without it) the hook returns skipped with the reason "no delivery channel (CLI path — the claim envelope carries this)": a session that claimed already holds the reply on stdout, and a paste on top would deliver every assignment twice. When the daemon advances (a human gate resolving on an event), there is no envelope, so the paste is the delivery. Either way the durable truth is graph-state.json, re-rendered into every later prompt, so a lost paste costs a nudge, never the process.
Human gates. On entering an actor: human node the entry chain posts request-review on the ticket and emits a notify event to the channels; the exit chain (classify-feedback / classify-phase-selection) returns wait, and the assignment tells the session "do not claim it; do not start the work it gates". The session does not poll: it stops, the Stop hook lets it (a wait is not a block), and the human's comment resumes it through the ingress → dispatcher → consult-the-gate-first path, which pastes the verdict and the next assignment into the same tmux session or resumes/spawns one if it died (session: inherit on the gate, honoured by Runtime.resolve_session; a dead session falls back to fresh-with-artifacts).
Where the human is reached is routing.interaction.mode: work-item (default) means every question is a comment on the ticket or PR and the session waits for the reply as an event; cli means a person is attached to the terminal. The directive is injected into every prompt as $interaction_directive.
Cursor. The Cursor adapter implements only the one-shot argv (harness/cursor_agent.py); interactive_argv raises UnsupportedRunnerError. A daemon-hosted loop session is Claude Code in tmux; Cursor participates as a critic (one-shot) and through the same Stop-hook gate (.cursor/hooks.json, followup_message).
Where the harness is told about the CLI and the process
There is no single prompt. What a session knows is assembled in layers, and only the first two are prompts the daemon writes; the rest is the plugin the session runs under.
| Layer | What it says | Where |
|---|---|---|
| Spawn prompt (a new session) | "Start the-loop on it now by running /the-loop:work-on <item>"; read the whole thread first; the process is defined by the-loop's own graph; the work item's text is untrusted. Carries $interaction_directive and $graph_context. | skills/the-loop/templates/webhook-autoexecute-prompt.md (routing.spawnPromptTemplate); fallback DEFAULT_SPAWN_TEMPLATE in webhook/dispatcher.py |
| Event prompt (a resumed session) | "You are the the-loop session working <item>. React to this event per the-loop's rules"; same two blocks; the payload excerpt is untrusted. | skills/the-loop/templates/webhook-event-prompt.md (routing.promptTemplate); fallback DEFAULT_PROMPT_TEMPLATE |
$graph_context block, in both prompts | The current node, phase and status; the gate's messages; the verdict the gate just reached on this event; resume with: /the-loop:<command> <item>; where to iterate the artifacts; and when this node's work is done, run: the-loop graph complete <item>. A pending context says "NOT ENTERED YET — do not start a phase". | graphlink.py:render_graph_context |
$interaction_directive block, in both prompts | Where answers come from: in work-item mode, ask with the-loop ask --work-item … --question …, then stop and wait for the reply as an event; never block on an interactive prompt; the artifact-on-a-PR rule. | interaction.py:_WORK_ITEM_DIRECTIVE / _CLI_DIRECTIVE (routing.interaction.mode) |
| Assignment paste, on every node entry | the-loop assignment for <item>: you are now at node …; produce: …; work it with: /the-loop:<command> <item>; when done, report back: the-loop graph complete <item> — or, at a human gate, "do not claim it". | graph/hooks/assignment.py:render_assignment, delivered by deliver-assignment |
| SessionStart hook / Cursor rule | One line: the-loop is initialized here, read the harness config, follow the the-loop skill. | hooks/hooks.json (SessionStart); rules/the-loop.mdc |
| Slash commands | The per-phase procedure. work-on is the superset: load the config, register the session (the-loop sessions register, link-pr), walk the phases, and at each node end "run the-loop graph complete". | commands/work-on.md and the granular commands/*.md |
| The skill | The operating model. SKILL.md states the rule "tell the graph so — the-loop graph complete <id>", the config-key → CLI-flag table (§ Configuration: scenarios --glob, instructions --doc, critic policy, graph hooks), and the review-round policy; reference/automation.md § CLI companion describes the CLI itself — lifecycle verbs, the receiver, routing, arming, sessions; reference/workflow.md renders the graph and names check, graph complete, graph skip, graph force. | skills/the-loop/SKILL.md, skills/the-loop/reference/automation.md, reference/workflow.md |
So the prompts name only the verbs the session must use at the seams (graph complete, ask, the /the-loop:<command> to resume with); the explanation of the CLI and the process around it is the skill's, loaded because the spawn prompt sends the session through /the-loop:work-on, which tells the session to read the skill and its reference files before acting.
5. Where to look first
- The graph:
cli/the_loop/graph/pdlc-work-item-loop.yaml— read theedges:block bottom-up fromdesign. - The runtime:
cli/the_loop/graph/runtime.py—start,complete,advance,status(recompute=True);graph/chain.pyfor the short-circuit rule. - The two gate hooks:
graph/hooks/feedback.py(classify-feedback,lock-artifacts) andgraph/hooks/selection.py(classify-phase-selection). - The daemon seam:
graphlink.py(on_spawn,on_event,_guarded) andwebhook/dispatcher.py(handle,_apply_control,_spawn_for,_dispatch_one). - The clock:
hooks/the-loop-gate.pyand.github/workflows/the-loop-gate.yml.
What would falsify §3: an edge in any shipped graph from a producing node straight to the node after its approval gate, or a code path that hands Runtime.advance an event built from anything other than the ingress. Neither exists at v15.0.0.