Capability: process-graph
the-loop's PDLC as an executable graph: nodes are the steps, hooks are the checks and side effects that run at their boundaries, and declared edges route on hook outcomes. Prose describes the process; the graph is the process.
What it is
The runtime under cli/the_loop/graph/ plus the shipped loop definitions (cli/the_loop/graph/pdlc-work-item-loop.yaml, pdlc-pr-loop.yaml, pdlc-contribution-loop.yaml, pdlc-adhoc-loop.yaml and pdlc-review-loop.yaml), plus any graphs the operator declares of their own (the top-level graphs, issue-343), surfaced as the-loop check and the-loop graph. It exists because before it, the PDLC was enforced only by prompts: there was no event anywhere in the-loop meaning "this node of the process completed", so there was nowhere to hang a gate, a notification, or an advance (issue-109, decision-041).
There are exactly two runtime concepts and one contract between them.
Current behaviour
The graph
- The PDLC SHALL be declared as data — nodes and edges in
cli/the_loop/graph/pdlc-work-item-loop.yaml, versioned and validated against its schema — and the runtime SHALL execute that declaration rather than re-deriving the process from prose. - The runtime publishes the lifecycle (issue-378, decision-130). Beside the
graph.started,graph.advanced,graph.completedandgraph.cleanedlines it already writes, the runtime SHALL publishphase.startedandphase.completedon the channel bus — for every graph, shipped or custom, with nonotifyhook — following theloop:<phase>label: the phase the walk is in is the state file'sphase(the last node entered that declares one), so two nodes under one phase are silent, a node without a phase inherits the one before it, and a force (no entry chain, no label) publishes nothing.cleanupstarts its own phase and completes none. A config with nochannelssection publishes nothing and the transition is unchanged. See channels. - The process is two loops (issue-172, decision-065). The outer
pdlc-work-item-loopwalks a work item through the full PDLC, exactly as the single graph always did. The innerpdlc-pr-loopwalks one pull request through the component-scoped subset that delivers it — starting atimplementation(everything earlier is the work item's, decided once at the outer level), through verification and the same review chain, to the PR's own human gate (pr-approval) and a terminalcomplete. Same vocabulary, same hooks, same runtime; apdlc-project-management-loopis anticipated by the naming and not yet shipped. - The third loop is the contribution loop (issue-185, decision-070).
pdlc-contribution-loopSHALL be walked instead of the outer loop when an authorized user arms a work item with thecontributecontrol keyword — the-loop joining an existing, in-progress issue or PR as a contributor, with no assumption that any spec-chain artifact exists.- The loop SHALL NOT start until an authorized user's comment states a goal and at least one success criterion (
Goal:+ aSuccess criteria:bullet list); thegoal-definitionnode isrequired: true, the parsed goal is frozen intowork-item-state.json's decisions with provenance and confirmed in a comment, and unauthorized or self-authored text SHALL NOT be read at all. - Its planning nodes (
context-intake,scoped-plan,plan-approval) SHALL author one artifact —contribution.md— in place of the four-file spec chain; itsverificationnode SHALL block until every success-criterion checkbox is complete andVerification resultsis recorded (inevidence/verification.mdwhen the plan was declared away). Every node butgoal-definitionandphase-selectionis skippable (skip setsplanandreview-chain). - A contribution has no outer loop, so it SHALL NOT be asked where to put one (issue-199). The
phase-selectionchecklist SHALL omit theouter-loop-on-pull-requestrow for this loop, the token SHALL be ignored if a reply carries it anyway, the confirmation SHALL claim no surface, and the frozen record SHALL carry an empty surface — the fact "never asked", which is not the fact "the default was kept". The session's$graph_contextSHALL likewise place its work on the work item without naming an outer loop. - Which loop a work item walks SHALL be recorded durably:
GraphState.loop, written once at start from the compiled graph's name, resolved state-first (then the portable control record, then the default) by the daemon and every CLI verb. Only shipped loop names SHALL be honoured — an invented value in the agent-writable state file reads as the default, never as a graph choice. - A contribution is a guest (PR #187 review; keyed on the loop since issue-352, decision-123): the-loop installs nothing in the repository it was invited into — and since issue-352 the CLI installs nothing in any repository, adoption having gone with the harness-config reads. WHEN the runtime's loop is a guest loop (
config.guestLoop) THEN the spec tree SHALL be kept out of the repository's history structurally (Runtime.startwrites the spec root into the checkout's git exclude file — the contribution PR carries only the intervention), and thepublish-artifacthook SHALL postcontribution.md's content to the thread atplan-approvalandhuman-approval— the review surface a guest has. In the work item's own loops the hook SHALL skip: the checked-in artifact is the surface, and no extra comment is posted.
- The loop SHALL NOT start until an authorized user's comment states a goal and at least one success criterion (
- The fourth loop is the ad-hoc loop (issue-225, decision-083).
pdlc-adhoc-loopSHALL be walked instead of the outer loop when an authorized user arms a work item with thedocontrol keyword — a tactical task that runs no PDLC process at all. It is the answer to "doescontributefit an ad-hoc task?": it does not, becausepdlc-contribution-loopis defined by the tworequired: truegates an ad-hoc task has no content for — a stated goal with success criteria, and a verification node that blocks until each is proved.- The loop SHALL declare exactly three walkable nodes —
work(agent, phaseimplementation),review(human) andcomplete(phasecomplete) — plus the terminalcleanupandescalatednodes, with edgeswork --pass--> review,review --more-work--> workandreview --done--> complete. - It SHALL declare no
producesand novalidate-artifactsentry (no spec chain, nocontribution.md, no evidence tree), nogoal-definitionand nophase-selectionnode, and therefore noskipSetsand noskippableorrequirednode. The issue-177/179 invariant — every phase that does not run carries a named human's attribution — SHALL hold by construction rather than by gate: nothing is skipped because the loop declares nothing to skip, and arming withthe-loop doIS that named, authorized, durably recorded declaration. - The
reviewnode SHALL classify the requester's reply throughclassify-adhoc-replyinto exactly two outcomes. WHEN an authorized reply declares completion THEN the outcome SHALL bedone; WHEN an authorized reply says anything else THEN it SHALL bemore-work— the inverse ofclassify-feedback's wait-until-decisive default, which is correct for a review gate and wrong for a conversational one. WHILE no authorized, non-self-authored reply exists the gate SHALL stay open. The newest authorized comment SHALL decide, so a done-word in the arming comment ("tell me when you're done") cannot end the item before any work has run. The hook SHALL reusefeedback._authorized_comments, so self-authored text is dropped before authorization is considered and an emptyauthorizedUsersreads nothing. - "Until the user closes the work item" SHALL need no new machinery: the existing close path (a closed issue, or a merged/closed PR) already ends the session (issue-94), and this loop inherits it.
- It SHALL reuse the existing phase vocabulary (
implementation,complete,cleanup) — the graph's, the only phase list since issue-352. - Unlike a contribution, an ad-hoc item is not a guest: its spec tree is the work item's own. The harness config, where the repository has one, is what supplies the agent the test and lint commands the ad-hoc session still runs.
- Which non-default outer-path loop a recorded name selects SHALL be decided in exactly one place,
graph.model.resolve_outer_loop, which returns""for the default, for the inner loop (addressed by pull-request number, never by name) and for anything invented — the fail-closed rule the state file's agent-writability demands, now localized instead of copied per reader.
- The loop SHALL declare exactly three walkable nodes —
- The fifth loop is the review loop (issue-279, decision-101).
pdlc-review-loopSHALL be walked instead of the outer loop when an authorized user arms a thread with thereviewcontrol keyword — the-loop as the reviewer of a change, never its author. Its product is judgement posted on the thread, so the session SHALL change no code: it commits nothing, pushes nothing and opens no pull request; a finding worth fixing is a new work item.- Typed on a pull request, the review SHALL bind to the pull request's own ref — control record, spawn and work-item state alike — even when the PR links work items (the router's linked-first ordering is right for delivery and wrong for a review); on a plain issue the ordinary target stands.
- The loop SHALL declare exactly four walkable nodes —
review-brief(human,required: true),review(agent, phaseneeds-review, stagecritic-review),follow-up(human) andcomplete— plus the terminalcleanupandescalatednodes, with edgesreview-brief --briefed--> review --pass--> follow-up,follow-up --more-work--> reviewandfollow-up --done--> complete. - No brief, no review. The
review-briefgate SHALL post a fill-in template (questions / angles / validations) idempotently — and not at all when the brief rode in on the arming comment — then freeze the newest authorized, non-self-authored brief intowork-item-state.json's decisions with provenance and confirm it in a comment. Unauthorized or self-authored text SHALL NOT be read at all, and the gate SHALL re-read the whole thread because the arming comment is consumed by the control path. - Each
reviewround SHALL answer every question, examine every angle and run every requested validation (or state why one could not run), posted as one self-marked comment. Thefollow-upgate SHALL reuseclassify-adhoc-reply(decision-101): an authorized reply that declares completion isdone, anything else is another round, silence keeps the gate open. - A work item is reviewable too (R8, PR #280): armed on a work item, the loop runs one review conversation across every pull request delivering it. The template SHALL additionally ask which pull requests the review spans, pre-filled with the ones the-loop recorded —
work-item-state.json'spullRequests[]first, then its ownpr-loops/state, best-effort — and stated entries SHALL be normalized to composed refs, unparseable bullets dropped, the scope frozen with the brief. It Where the work item is a pull request —the-loop reviewandthe-loop contributeare armed on one — that pull request SHALL itself be recorded inpullRequests[]at arming, markedself: trueand carrying nostateDir(issue-370, R7): the entity the-loop manages is a work item whatever represents it, so one list answers "which pull requests does this work item involve?" for an issue and a pull request alike. Whether the work item is a pull request SHALL be read from the arming event, never from the selected loop and never by asking the provider. The pre-fill SHALL ask GitHub nothing: thelinked-pullsintegration op was retired by issue-370, because a pull request is in scope when the-loop opened it and said so, and the provider's answer could neither see a spec pull request (which closes nothing) nor exclude a stranger's. A brief with no pull requests reviews the work item itself; a pull-request list alone is not a brief. - It SHALL declare no
produces, novalidate-artifacts, nophase-selectionand no skip vocabulary — arming withthe-loop reviewIS the named, authorized, durably recorded declaration (the issue-177/179 invariant, held as the ad-hoc loop holds it) — and SHALL reuse the existing phase vocabulary (needs-review,complete,cleanup). - A review is a guest (
GUEST_LOOPS, generalizing the contribution carve-out): its spec tree (the work-item-state cache) SHALL stay out of git via theguestLoopseam, and it installs nothing in the repository it reviews.
- Every work-item-level loop ends at a
cleanupnode (issue-186).pdlc-work-item-loop,pdlc-contribution-loop,pdlc-adhoc-loopandpdlc-review-loopSHALL each declare a terminalcleanupnode — phasecleanup,actor: code, entry chainset-phase-label+log-entry— that the-loop enters just before it releases the work item's local resources (its tmux sessions, its workspace checkout, its machine-local session record), so the teardown is a transition with a timestamp and aloop:cleanuplabel rather than a silent side effect.pdlc-pr-loopSHALL declare none: a pull request owns no checkout of its own.- It SHALL have no inbound edge, like
escalated: it is not a node a work item walks into by satisfying a gate, socompletestays terminal (whichawait-inner-loopsand the PR-merge path both read). Entering it from mid-walk — a work item closed unfinished — is legitimate and honest: the earlier nodes keep whatever they evaluated to, andcheck --recomputestill reports every gate that never ran. - The move SHALL NOT be a
force: no gate is bypassed and no verdict is claimed, so nothing is recorded as forced and no override announcement is posted. It is idempotent, and a no-op for a work item that never entered the graph. - It is the one graph action exempt from the start requirement, because a cleanup disarms the work item by the very command that asks for it — every other gate (the checkout-ownership proof, spec-directory containment) still applies.
- The loops SHALL meet at exactly one seam: the outer
implementationnode'sawait-inner-loopsexit hook. WHEN inner loops have been started underdocs/specs/<id>/pr-loops/THEN the work item SHALLwaitatimplementation, naming the pending PRs, until every one of them reaches itscompletenode — "wait for tasks to be complete (inner loop start and finish)" — after which verification runs across all the PRs. WHEN none was ever started THEN the gate SHALL pass vacuously: a single-session work item behaves exactly as before issue-172. - Each inner loop's state SHALL live at
docs/specs/<id>/pr-loops/pr-<number>/work-item-state.json— beside the outerwork-item-state.json, checked in, a cache and never an authority. Artifacts SHALL resolve against the work item's one spec chain: a PR does not get a spec chain of its own. An unreadable inner state SHALL hold the outer gate (naming the PR), never release it.
- It SHALL have no inbound edge, like
- The loops run in named places when the work spans repositories (issue-183, decision-069). The origin repository is the one the ticket was created in — the work item's ref as the daemon knows it, or the checkout's
originremote in-session (issue-352; until thenticketing.github).- The outer loop SHALL walk in the origin repository, and the work item's one spec chain SHALL live there. WHEN a work item needs contributions in n repositories THEN n pull requests SHALL be raised — one per repository, each walking its own
pdlc-pr-loop— and no implementation pull request SHALL be raised in the origin repository unless it is itself one of the n. - WHEN a pull request is in a repository other than the origin THEN its inner-loop state SHALL live at
docs/specs/<id>/pr-loops/<owner>__<repo>/pr-<n>/, in the origin repository's checkout; WHILE it is in the origin repository the shippedpr-loops/pr-<n>/path SHALL be kept, so no work item in flight needs migrating. - IF a repository value reaching that layer is not
<owner>/<repo>with each segment matching[A-Za-z0-9._-]+(never.or..) THEN the-loop SHALL raise rather than resolve a path from it — the value becomes a directory name and arrives from a payload or an operator's--pr-repo. - WHEN a pull request carries a closing reference qualified with another repository (
Closes <owner>/<repo>#<n>, its URL form, or aclosingIssuesReferencesentry naming its repository) THEN routing SHALL map the event to the work item in that repository. This widens which work item an arrived event names — never which events arrive, nor which work items are armed. - WHEN
work-item-state.jsondeclaresrepos: [<owner>/<repo>, …]— written bythe-loop graph repos, the AGENT's verb, oncedesign.mdandtasks.mdsay what the change spans — THENawait-inner-loopsSHALL holdimplementationuntil each declared repository has at least one inner loop and every started loop has reachedcomplete; a declared repository with no loop SHALL be named in the wait. IF a declared entry is not a usable repository name THEN the gate SHALL block — waiting on it would wait forever. IF no repositories are declared THEN the gate SHALL behave exactly as it did before issue-183. - Which model a work item's sessions run on, and at what effort, SHALL be declared per work item at
phase-selectiontoo (issue-358, decision-124) — two independent row groups (model-<name>,effort-<level>) answered by the same authorized reply, resolved per section so an ambiguous model cannot discard a valid effort. A section SHALL be rendered only with something to offer, and SHALL omit any choice this work item's harness is known to refuse, so a human cannot pick one that would leave a session dead. Exactly one ticked row is a choice; none, several, an unknown token and an unreadable checklist all mean no choice — which is a different recorded fact from "the default", and is what lets a later change to the operator's configuration apply to the item. Both are written to the frozen record besidesurfaceandsessionPerPr, and the confirmation names each outcome in both directions. - Which harness a work item's sessions run on SHALL be declared at
phase-selectionas a third row group,harness-<name>, rendered first and only when two or more declared harnesses can host a work item's session (issue-440). It is resolved first, by the same one-tick rule; the model and effort rows are then offered across every offered harness and kept only if the resolved harness can run them — a tick that cannot is dropped and named in the confirmation.harnessis frozen besidemodelandeffort, and the default harness the checklist names is the one the daemon spawns on (modelchoice.default_harness). - WHEN a work item is armed and its pointer parks on a human gate that is the graph's own start node THEN the graph SHALL be entered and no session SHALL be spawned (issue-358, R8). The node's work is the daemon's:
phase-selectionposts its checklist through the CLI's github integration, and the arming event is handed to that first gate exactly as issue-199 established — only earlier. The session is spawned when an authorized reply unparks the pointer, so it carries the model that gate froze. Deferral SHALL apply only while the pointer has never left the start node and that node waits on a human; a pointer that has moved, an agent node, and every existing graph-link skip path SHALL spawn as before. - Where the outer loop's artifacts are iterated with humans SHALL be declared per work item at
phase-selection, by the same authorized reply that freezes the phase selection: one extra checklist row (outer-loop-on-pull-request) whose resolved value is written towork-item-state.jsonand to the portable record. IF it is unticked or absent THEN the surface SHALL be the work item itself — the default, because a work item only opens a pull request in the origin repository when its author asks for one. It SHALL NOT be a key in the harness config or the CLI config: one repository has both a one-repo bugfix and a three-repo migration (owner's call, PR #184). The artifacts SHALL be committed files linked from the ticket in either case, and the inner loop SHALL have no such choice: a pull request's loop is iterated on that pull request. WHEN a session enters a node THEN its assignment SHALL name the surface it is working on, and a cross-repo claim command SHALL carry--pr-repo. - The same reply SHALL resolve one more non-phase row,
claude-artifact(issue-471), offered unticked wherever the surface row is. Ticked, it SHALL freezeclaudeArtifact: trueonly when the work item's resolved harness isclaudeor unknown; on a known other harness it SHALL freezefalseand the confirmation SHALL name that harness. The value SHALL be read back only as the booleantrue, and the outer loop's graph-context block SHALL then carry one fixed line telling the session to publish the spec chain as one Claude artifact. It SHALL NOT route the pointer, declare a skip or answer a gate. - The same reply SHALL resolve one more non-phase row,
explainer(issue-488), rendered ticked wherever the graph defines theexplainerskip set (onlypdlc-work-item-loop). A row present and unticked SHALL declarespec-explainer,spec-quiz,code-explainerandcode-quizskipped, each withtoken: explainerand the replier's name; absent or unreadable SHALL mean on. The four nodes SHALL NOT appear as phase rows.
- The outer loop SHALL walk in the origin repository, and the work item's one spec chain SHALL live there. WHEN a work item needs contributions in n repositories THEN n pull requests SHALL be raised — one per repository, each walking its own
- The quiz gates (issue-488, decision-143): WHEN
spec-quizorcode-quizis entered THENpost-quiz-attestationSHALL post one marked comment linking the agent node's recordedLocation— on the ticket, or on every open delivering pull request (the ticket when there is none) — carrying the node, its round and, for the spec quiz, a digest of the spec files; a record with noLocationSHALL block. On GitHub the comment holds one checkbox; on Jira, which cannot say who edited a comment, it asks for the line as a reply instead. Atmicrodepth (the record'sDepth) the line attests reading only, and only the line for the record's depth counts. The exit hookquiz-attestedSHALL count a quiz comment only when an authorized login wrote it (the-loop posts as the operator) or, on Jira, the service account did; it SHALL pass only when the latest edit of that comment, as GitHub's GraphQL edit history reports it, was made by an authorized user — and every earlier edit too, since the history never says what an edit changed — or an authorized comment carries the ticked line itself. An unknown editor, a failed query or a forged comment SHALL keep it waiting, with a refused tick emitted asgraph.quiz_refused; a changed spec SHALL routespec-quizback tospec-explaineronstale; a comment that never landed SHALL be posted again by the exit hook.spec-explainer's exit (explainer-postdates-spec) SHALL block while the spec has an uncommitted change or a commit the record's commit does not descend from (R5.6: changed after the page was published). The poller and anissue_comment.editedwebhook SHALL ring a waiting gate with aquiz_attestationdoorbell that renders no prompt and never spawns, deduplicated per tick (an untick and re-tick rings again; an unchanged tick does not). - WHEN a work item's frozen selection does not list a skippable node of the live graph THEN the runtime SHALL skip it as
not-offered(issue-488): nobody chose it and nobody declined it, so an in-flight item never waits at a gate it was never offered.- The daemon SHALL drive each inner loop from its PR's own session (the
pullRequests[]endpoint,routing.tmux.sessionPerPr): the endpoint's spawn enters the loop atimplementation, its events advance it, and the work item's outer loop is NEVER advanced by a PR's events — the outer loop hears about inner ones only through the state filesawait-inner-loopsreads. Which pull requests have a session to drive an inner loop from is the work item's own frozensessionPerPr(issue-260), falling back torouting.tmux.sessionPerPr(issue-258): undercross-repositoryit is a pull request in another repository, so an inner loop is what a cross-repository contribution walks and a pull request in the work item's own repository has no second session — the work item walks its outer loop alone, the "one agent, one session" shapeawait-inner-loopsalready passes vacuously.alwaysextends inner loops to same-repository pull requests that get a checkout of their own, andnevercollapses every one of them onto the outer loop. WHEN the PR merges THEN its loop SHALL be driven tocompleteas a forced transition (reason recorded; a force moves the pointer, never forges a verdict), because a merge is the PR's approval delivered as a state change. A PR closed WITHOUT merging SHALL keep its pointer where it was: abandoned is not finished, and the outer gate holding is the process noticing. - Every graph verb SHALL address either loop:
the-loop graph status|advance|complete|force|show --pr <n>(andpron the corresponding API bodies) selects the PR's inner loop; omitted, the work item's outer loop — the whole pre-issue-172 surface, unchanged.--pr-repo <owner>/<repo>(andprRepoon the API) qualifies--prfor a pull request outside the origin repository (issue-183); without--prit SHALL be refused, because a repository does not identify a loop. - The graph assigns, not just judges (decision-065 D8). WHEN either loop enters a non-terminal agent node on the daemon path THEN the
deliver-assignmententry hook SHALL push that node's assignment — where the item stands, what to produce, the exact claim command (the-loop graph complete <id> [--pr <n>]) — into the loop's bound session (graph.assignment_delivered), so the session is told its work rather than inferring it from the next GitHub event. The text is composed only from the-loop's own vocabulary — no payload reaches it. WHEN there is no delivery channel — a session's owngraph complete,the-loop check, any CLI invocation — THEN the hook SHALL skip: the claim's JSON envelope already carries the same facts. A failed push SHALL be recorded (graph.assignment_failed) and SHALL never gate the node.
- The daemon SHALL drive each inner loop from its PR's own session (the
- The shipped graphs SHALL be internal to the-loop: they ship as package data inside the CLI — the thing that executes them, and where every hook they name is registered — and a consuming repository does not define or override them. A repo-local
.the-loop/graph.yamlSHALL be ignored with a warning (R1.5). User-authored graphs arrived as the deliberate feature this held the door for: the operator's own, declared in the CLI config (issue-343, § Graphs of the operator's own). - A node SHALL be one step of the process, with an ordered
entryhook chain and an orderedexithook chain. A node is complete when its exit chain all passes, waiting when a hook returnswait, and blocked when a hook returnsblock. No prose is parsed to decide this. - A node MAY be declared
optional(skipped when its artifact is absent — brainstorming) orrequired(never skippable, even by an optional-looking gate). Since issue-179 the outer loop declaresrequiredon exactly one node:phase-selection. - A node MAY carry a one-line
description, rendered beside its row on the phase-selection checklist. It is shipped-graph text — a repository cannot supply it — and it exists so a phase a reader has to guess at is not a phase they decline by default.
Declared skips (issue-177, widened by issue-179)
The author of a work item — never the harness — decides which phases it walks (decision-067):
- A node MAY be declared
skippable: true— the fixed vocabulary of what a human may skip.requiredandskippableon one node SHALL fail at compile time, as SHALL a skippable node without its ownon: skippededge: routing around a node is authored, never inferred. - In the outer loop that vocabulary is every node it walks (issue-179, decision-068) — the spec chain,
test-planning,implementation,verification, the review chain,security-reviewandhuman-approval— exceptphase-selectionand the terminals.phase-selectionSHALL remainrequired: trueand unskippable: the loop can never walk past the act of choosing, which is what makes every omission attributable to a named human decided before any work starts. The floor is that one invariant, not a set of phases. The innerpdlc-pr-loopdeclares no skippable node and keepssecurity-reviewrequired: true. - The graph MAY ship named
skipSets(the outer loop shipsspec-chainandreview-chain); a member that is not a declared skippable node SHALL fail at compile time — a set cannot widen the vocabulary. - Only a human declares, at the loop's own first phase. The outer loop SHALL start at
phase-selection, a human node: its entry posts a checklist of the selectable phases on the ticket (idempotent — a second entry finds its own marker and does not re-post), and its exit SHALL wait until an authorized user (authorizedUsers, the boundary every other human gate uses) says the execute keyword (routing.control.keywords.execute, defaultthe-loop execute).- The selection is ticked in place on the-loop's own comment; the authorized execute comment is what makes that tick state theirs. A checklist inside the execute comment itself SHALL win over the boxes. A selection with nothing unticked SHALL run the full process.
- Unticked skippable phases become declared skips; a phase the reply never mentions is kept; an unticked protected phase SHALL be refused and named in the confirmation.
executeis arouting.controlcommand likestart, and carries the same named-actor authorization — but it touches no session, and the comment carrying it is still delivered, because the gate is what reads the selection.- How many tmux+claude sessions this work item's pull requests get SHALL be chosen here too (issue-260), on the same checklist and by the same signed reply: three rows —
pr-sessions-never,pr-sessions-cross-repository,pr-sessions-always— with the deployment'srouting.tmux.sessionPerPrrendered pre-ticked. Exactly one ticked row SHALL be the choice; none ticked, several ticked, an unreadable checklist and a token outside those three SHALL all resolve to that configured default. The resolved mode SHALL be written towork-item-state.jsonand to the portable record (graph.sessionPerPr), named in the confirmation, and read by the daemon per work item in preference to the config key — which is therefore a default and not a verdict, for the same reason the surface is not a config key at all. The rows SHALL be offered by every loop that reaches this gate, the contribution loop included: unlike the surface, the question has a true answer there. A row SHALL never be read as a phase, whichever way it is ticked.
- The selection freezes the graph. WHEN the gate is answered THEN the resolved graph — every node with whether it is walked, and whether it was selectable — SHALL be recorded in
work-item-state.json, so what the loop will walk is a recorded fact rather than a comment anyone can keep editing. Since issue-368 that file is the only copy: the answers that used to be published to the portable record as well —sessionPerPr,modelandeffort— are fields of the state file besidesurface,skipsandoptIns, and the daemon reads them from the checkout the work item's session record names. A work item frozen before that change has none of the keys and agraphsection in its portable record; the daemon SHALL read that copy and SHALL NOT rewrite it, so an item in flight keeps its routing across the upgrade (decision-128). - An operator MAY declare the same from a shell via
the-loop graph skip <id> --node <token> --reason <why>—force's sibling: reason required, audit comment posted, recorded asgraph.skips_declared— and a token naming a node the pointer has already reached SHALL be refused: a skip is a plan, not an amnesty. - A declaration SHALL never apply to a node already entered, whichever channel it came from, and SHALL be filtered through the compiled graph's
skippablevocabulary on every read — so no hook can declare a skip the graph does not permit. - A skip routes and records; it never forges. WHEN the pointer would enter a declared-skipped node THEN it SHALL take that node's
skippededge, run none of its hooks (no phase label, no assignment), record outcomeskipped(graph.node_skipped), and land on the first non-skipped node.the-loop check—--recomputeincluded — SHALL report the node as skipped by declaration with its provenance, never aspass. A declaration on a non-skippable node (a hand-edited state file) SHALL be inert everywhere and surfaced on the node it tried to touch. - WHEN a later gate reads an artifact whose authoring node was declared-skipped and the artifact is absent THEN that slot SHALL be treated as a planned absence (
implementation'stasks.mdre-gate aftertasks-breakdownwas skipped); an artifact that exists SHALL be gated normally regardless of declarations. - A kept gate SHALL keep a subject (issue-179). A hook entry MAY declare
onlyWhenSkipped: <artifact>, and SHALL then apply only while every named artifact is a planned absence — authoring node declared-skipped and absent on disk — reportingskippedwith a reason otherwise. It reads only the runtime's filteredskipped_artifacts, so it SHALL only ever narrow a gate's applicability and can never widen what may be skipped. The shipped use isverification: withtest-planningdeclared away and notesting-plan.md, it gatesevidence/verification.mdfor a non-empty Verification results section instead, and blocks until it is written — skipping the plan removes the document, never the verifying. That file is produced by no node, so it can never become a planned absence itself (issue-365).
Opt-in phases (issue-188)
The other default, at the same gate and by the same person (decision-071):
- A node MAY be declared
optIn: true— off unless an authorized human selects it. It SHALL implyskippable(same vocabulary, same requiredon: skippededge, same routing and reporting), andrequired×optInSHALL fail at compile time. AskipSetsmember that is opt-in SHALL fail at compile time too: a set declares phases away, and an opt-in phase is already away. - The phase-selection checklist SHALL render opt-in nodes unticked, in a section of their own that states they do not run unless ticked, with each node's
description. Ticking one SHALL select it; leaving it unticked, or never naming it in the reply, SHALL leave it unselected. Every unreadable-input path SHALL resolve to not selected — the fail-closed direction for a phase that adds a review rather than gating one. - The phase-selection checklist SHALL be laid out to be skimmed (issue-472). It SHALL open with a titled question and a quick start (the one reply that runs the full process, and how to tailor it). It SHALL group the phase rows under one heading and every non-phase setting under a second. Each question SHALL be a heading opened by an emoji no other heading uses. Each setting SHALL state its default before its rows, and every explanation SHALL sit in a collapsed
<details>block that holds no row. The rows' shape and order SHALL NOT change with the layout, because they are what the reply parser and the Slack mirror read. Relayed to Slack,<details>SHALL be unfolded and a<summary>drawn as a bold line. - A selection SHALL be recorded in
work-item-state.jsonasoptIns[<node>] = {via, token, by, at}(graph.opt_ins_selected), filtered through the compiled graph on every read exactly asskipsis, and never applied to a node the pointer already entered. The frozen graph SHALL carryoptInper node so the portable record distinguishes a phase nobody asked for from a phase somebody removed. The confirmation comment SHALL name the selected opt-in phases, or state that the offered ones were not selected. - WHEN an opt-in node is not selected THEN the runtime SHALL route around it on its
skippededge, running none of its hooks, andthe-loop check—--recomputeincluded — SHALL report it as not selected, distinct from skipped by declaration (which names a human) and never aspass. A work item whose state predates the node SHALL therefore skip it rather than block on it. - The shipped opt-in phase is
design-critic-review(outer loop only), betweendesignandtest-planning: a different model reading the completeddesign.mdagainst the requirements before the testing plan and task DAG derive from it (it is locked later, atdesign-approval— issue-281), gatingevidence/design-critic-review.md. See review-loop. - An edge SHALL route on a hook outcome only (
on: pass,on: changes-requested, …). There is no expression language: the LLM produces facts, declared edges route on them. That split is what makes judgement and determinism coexist.
What a node produces
- A
producesentry SHALL name an artifact, not a filename. It MAY accept several names separated by|—produces: ["requirements.md|bugfix.md"]— because one artifact can legitimately go by more than one name: a bug's phase-1 spec is calledbugfix.md(decision-045). - Exactly one accepted name may be present. None SHALL block with a message naming every accepted name, so an agent knows what it is allowed to write; more than one SHALL block as ambiguous, because two artifacts filling one slot have no defined source of truth and a gate that quietly picks one can approve the stale one.
- An artifact found under an alternative name SHALL be held to the identical standard —
locked,frontMatter,sections,checkmarks. The name is what is flexible; the bar is not. - An entry with an empty alternative (
a||b,|a,a|) SHALL fail at graph-compile time, naming the node and the entry — every structural failure is a startup failure. - The entry SHALL be reported verbatim by
the-loop graph show, unsplit, so the output states what the graph declares rather than claiming two artifacts where it means one. enforces-boundaries-from'supstreamSHALL resolve the same way; when several accepted names are present their bodies are joined rather than one being chosen, so a boundary raised in either still has to be answered downstream.- The names the graph gates, the names
.the-loop/manifest.yamltracks and the templates underskills/the-loop/templates/SHALL agree, enforced in both directions bycli/tests/test_graph_parity.py— including that a bundled template offers every section the node it is authored for requires.
What a node validates (issue-167)
produces means the artifact this phase is judged by, which the manifest binds to a phase. A node that gates something else — the six review-chain nodes each gate one record under evidence/, and most of them carry no phase at all — declares it on the hook entry instead (decision-063):
exit:
- {hook: validate-artifacts, with: {validates: evidence/security-review.md, sections: ["Security review (gate)"]}}validatesSHALL be a hook parameter, not a node field: it describes one assertion, not the node's ownership, and onlyvalidate-artifactsreads it.- It SHALL resolve through the same resolver as
produces, so alternation, the absent-artifact block and the two-files-one-slot ambiguity block are identical for both and cannot drift apart. Every declared check (locked,frontMatter,sections,checkmarks) applies to a validated artifact exactly as to a produced one. - A validated artifact that is absent SHALL block, naming the file. It is never a skip: the node asserted the file would be there.
- A gate with nothing to read SHALL fail closed. When a
validate-artifactsentry declares any content check and resolves no artifact — neitherproducesnorvalidates— it SHALLblock, and the block SHALL be not retriable: re-running a node cannot repair the graph that declared it, and a retriable block would burnmaxAttemptsbefore anyone was told. - A node MAY gate several sections of the same artifact.
capability-docsgates two (issue-174, decision-066):## Capability docs— the organized view of specs, for a reader who already uses the project — and## Documentation— the user-facing surface (README.md, the docs site, the operating-model skill), for a reader who does not yet. They stay separate rows because folding them together would lose which of the two was skipped, and they share a node because a second node would cost an edge, astagekey and a place in both loops' entry chains to read a file this one already opens. The node keeps its id andstage:stage: capability-docsis the key the token-economy guidance's stage table is written against, so a rename would silently detach the node from its thinking-effort row. The inner loop gates neither — a work item's documentation is decided once, at the outer level. cli/tests/test_graph_parity.py's P5 SHALL enforce all three questions against the shipped graph: every content gate resolves a target (P5a), every validated name is tracked by the manifest (P5b), and every section it demands exists in that artifact's bundled template (P5c).- What this proves SHALL be stated rather than implied: the section check is structural, so a heading holding placeholder text passes it. The gate proves the record exists; the reviewer judges whether the review was any good.
Before this, those six nodes declared sections: and no artifact at all — so their validate-artifacts resolved nothing, returned skipped, and (a skip not being a decision) the chain passed straight through every one of them, security-review included, however empty the log was.
The hook contract
- Every hook SHALL have one signature —
(HookContext) -> HookResult— whereHookResultcarriesstatus(pass|block|wait|skip), the hook's name, orderedmessages, free-formdata, andretriable. - Hooks SHALL be discovered through a name registry (
@hook("validate-artifacts")), mirroring the CLI's existingCommand/@registerpattern, so the shipped graph refers to hooks by name and never by import path. - A chain SHALL short-circuit on the first result that is neither
passnorskip. Aggregation is therefore the hook's job: a validating hook reports every finding in one result rather than failing on the first and hiding the rest — a gate that reveals problems one at a time is a gate people learn to route around. - A
skipSHALL NOT be a decision (issue-163): a hook that declines to run has said nothing about the node, so the chain continues past it and, if nothing objects, the node passes on the outcomepass. Short-circuiting onskiphid the hooks behind a skipping one and routed a chain ending in a skip on the outcome"skip", for which no edge is declared — which is whyimplementation(whose chain ends in averify-teststhat is a no-op unless a command is bound) parked atno_edgeinstead of advancing. - A hook that raises SHALL become a
blockwithretriable=False, never a silent pass. - Hook results SHALL carry secret handles, never secret values (R2.7).
Shipped hooks
validate-artifacts · lint-artifacts · verify-tests · set-phase-label · log-entry · request-review · notify · classify-feedback · record-feedback · lock-artifacts · mcp-call.
validate-artifactsSHALL check front matter, required sections and the security boundary mapping (enforces-boundaries-from) — the design's Security design section must answer every abuse case the requirements raised.classify-feedbackSHALL turn a human's free-text review into one of the decisive outcomes via a schema-constrained harness call, and SHALL only accept feedback from authorized authors; anything indecisive keeps the gatewaiting rather than guessing (negative test: an unauthorized author's "lgtm" does not advance the node).lock-artifactsSHALL be the only thing that locks a gated artifact (issue-281): on an approval outcome from theclassify-feedbackresult of the same chain run it SHALL writestatus: approvedand merge the approving authors intoapprovedBy— as a front-matter splice that preserves every other line and comment — and SHALL verify the write landed, blocking (fail closed) when it cannot. On any other outcome it SHALL skip, leaving the artifact untouched; an absent artifact is a planned absence, and an ambiguous slot blocks. It SHALL never declare anoutcome, so the classifier alone routes the edge. Producing nodes SHALL NOT demandlocked:— one gate, one human approval, and a node with no gate (brainstorming,tasks-breakdown) needs no human sign-off at all.record-feedbackSHALL append approve-with-comments feedback to the artifact's own## Review commentssection, append-only and attributed. An approval never silently discards a reviewer's suggestions, and the feedback travels with the document it concerns instead of living in a side-channel tracker. The block it writes SHALL pass the project's configured markdown linter — the attribution is**@handle** wrote:, emphasis plus trailing text, because emphasis alone on a line is what MD036 rejects, and a comment with no body becomes a single**@handle** left no comment text.line rather than a run of blank ones. The reviewer's body SHALL NOT be rewritten to that end: it is recorded verbatim, so a body that fails lint on its own merits is a human's edit to make, never the recorder's (decision-089).set-phase-labelSHALL keep the ticket'sloop:<phase>label as the item's single position marker (issue-393). Setting the new label it SHALL remove any otherloop:*label the item carries, leaving every non-loop:label alone, so the item is in exactly one column of a board (B10 — before this the labels piled up); the removal is surgical, never a wholesale replace that would wipebugor the arming labels. WHEN the target label does not exist (a repository never/the-loop:init-ed) it SHALL create it and retry once (F3); the tidy and the create are both best-effort — a failure to remove a stale label or a non-missing-label error degrades (records and continues) rather than wedging the graph, because the marker a reader needs is the new label present.
A repository's own hooks (issue-248)
- An operator SHALL be able to bring hooks of their own and attach them to boundaries the shipped graph already declares, by declaring
routing.graph.hooksin their CLI config (issue-352 moved the declaration out of the repository's harness config, which the CLI no longer reads):modules[](apathto a.pyfile inside the checkout, or an installedmoduledotted name) andattach[](hook,node, optionalboundaryandwith). The declaration applies to every checkout the instance drives; a repository cannot opt its own code in. - A repository hook SHALL use the same contract as a shipped one —
(HookContext) -> HookResult, the same@hookdecorator, the same block-on-raise behaviour. There is one hook API, not two. - Every repository hook SHALL be named
x-<something>, and no shipped hook SHALL take such a name. The prefix says whose code is about to run and makes shadowing a shipped hook impossible. - An attachment SHALL be appended to the node's chain. There SHALL be no way to remove, reorder or replace a shipped hook, so a shipped gate always runs — and short-circuits — first: a repository hook can add a constraint and never relax one.
- A repository hook SHALL NOT route. An
outcomein itsdataSHALL be dropped and the drop logged, so it can neither classify a human gate nor select an edge; its influence on movement is itsstatusalone. - Repository hooks SHALL be resolved from a per-repository table on the compiled graph, never the process-global registry: a daemon walking two repositories that both define
x-house-rulesSHALL run each repository's own. - A declaration that cannot be honoured SHALL fail the graph load, naming it — a missing, unreadable, raising or empty module, a name outside
x-, a duplicate, an unknown node or boundary, or apaththat escapes the repository (absolute,.., or a symlink out). Nothing SHALL degrade to "no hooks": a gate the repository asked for either runs or is loudly absent. (A repository graph file is still merely ignored — the-loop never promised to honour that one.) - The modules run inside the-loop's own process, so
the-loop graph hooksSHALL report what the CLI config declares without importing any of it. There is no refusal switch (routing.graph.repoHookswas removed in issue-352): a module nobody declared never runs. - A module SHALL be imported once per process. Nodes, edges and loops are not repository-authorable; the operator's own graphs are the other half of issue-109's deferred item (issue-343, below).
Graphs of the operator's own (issue-343)
- An operator SHALL be able to declare graphs of their own in their CLI config, in two declarations kept apart (the owner's shape, PR #425 review). Which graphs exist is the top-level
graphs[]: each entry aname, apath(absolute,~/…, or relative to the config file's directory — never a work item's checkout) and an optionalguest. Which command selects which graph isrouting.control.commands:<word>: {graph, keyword?}. Absent, both SHALL change nothing. The loader SHALL fan the list intorouting._graphsfor the readers handed the routing block alone (cli_config.apply_graphs, asinstancetravels as_instance). A repository SHALL still not supply a graph: a.the-loop/<name>.yamlis ignored with a warning naming the top-levelgraphs. - A name SHALL match
^[a-z][a-z0-9-]*$, SHALL be unique, and SHALL NOT be a shipped loop's name or begin withpdlc-(reserved for loops the-loop ships). A malformed entry SHALL fail the load naming it. - A custom graph SHALL be compiled by the same compiler as the shipped loops. Every hook it names SHALL be shipped or an
x-hook a module inrouting.graph.hooks.modulesregisters (bound at load; a name no module registers fails it). EveryphaseSHALL be one ofPHASE_VOCABULARY— the shipped loops' phases, pinned by a test — because theloop:<phase>labels are one vocabulary. A top-levelname:, if present, SHALL equal the declared name. - A node's
command:SHALL match^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)?$in every graph. A bare name renders as/the-loop:<name>; a namespaced one as/<plugin>:<name>(graph.model.slash_command), which is how a custom graph points a session at another plugin's command. routing.control.commandsSHALL bind command words to graphs. A command with no entry SHALL keep its shipped loop.start,contribute,doorrevieware re-pointed to the named graph; any other word not a built-in command is a new command whose keyword is itskeyword, elsethe-loop <word>, parsed by the same whole-token, case-insensitive rule and taking part in the two-commands ambiguity refusal.graphSHALL name a shipped outer-path loop or a declared one; anything else fails the load. The non-arming commands SHALL be refused, as SHALL akeywordon a built-in command (that lives inrouting.control.keywords) and a new keyword equal to one already configured.- A new command SHALL parse as
startwithloopset (ControlResult.loop), and an overridden one as itself withloopset: authorization, spawn policy and arming arestart's, unchanged, andreviewkeeps its binding to the pull request it was typed on. The dispatcher SHALL record the loop on the control record (ControlRecord.loop) and on thecontrol.commandevent. - Loop selection SHALL stay state-first. Before the first start the control record's
loopselects, then its command throughLOOP_FOR_CONTROL_COMMAND. Every reader SHALL resolve a recorded name throughresolve_outer_loop(name, declared), which accepts a custom name only while the operator declares it — the state file is agent-writable, so any other name is ignored: the recording command's current binding applies, else its shipped loop (do→ the ad-hoc loop), else the default. The same holds with no loop recorded at all — a CLIsessions start, a spawn with no start required — so an overriddenstartapplies however the item was armed. A declared graph that fails to load SHALL fail loudly; it SHALL NOT fall back to the default. guest: trueSHALL make the runtime a guest (guestLoop): the spec tree is kept out of git andpublish-artifactposts the plan to the thread. The session-prompt lines tied to the shipped contribution and review loops (no outer loop; change no code) are not applied to a custom graph.- An attachment (
routing.graph.hooks.attach[]) MAY carryloops, scoping it to those loops (shipped or declared); unscoped it SHALL apply to every loop as before. An unknown loop name SHALL fail the load. the-loop graph loopsSHALL list every shipped and declared loop with the keywords that arm it, read the CLI config strictly, compile each declared graph and check the attachments that apply to it and that itsx-hooks have a module to come from — without importing a hook module — and exit 1 when anything fails. A command word SHALL NOT be one of the-loop's own CLI verbs (graph,check, …), so a comment quoting one never arms a work item.
Testing is planned and verified as nodes (issue-163)
test-planningSHALL sit betweendesignanddesign-approvaland producetesting-plan.md, gating on the artifact carrying non-empty Test matrix, Verification environment, Evidence plan and Verification results sections (never on it being locked — issue-281). Placing it before the human gate means one approval coversdesign.mdand the plan derived from it — the plan gets human review without a stop of its own, anddesign-approval'slock-artifactslocks both before thetasks.mdthat references its rows.design-approvalSHALL record feedback into both artifacts, because a reviewer's note about the test matrix belongs in the plan rather than filed under the design, andchanges-requestedSHALL return todesign, which re-derives the plan. The results heading is gated at planning time deliberately:validate-artifactstreats an empty required section as a finding, so the heading is authored up front holding "not yet executed" and the verification node fills a section rather than inventing one.verificationSHALL sit betweenimplementationandself-reviewand re-declare the same artifact, gating oncheckmarks: completeplus a non-empty Verification results section — the produce-then-re-gate shapeimplementationalready uses fortasks.md. Re-declaringproducesis what makes the gate run at all: a node that declares no artifacts gets a skippedvalidate-artifacts, which is a gate reporting success without running. WHENtest-planningwas declared skipped and no plan exists THEN the same reasoning applies one level up (issue-179): the artifact gate takes its planned-absence branch, soverificationSHALL gateevidence/verification.md's Verification results section instead (onlyWhenSkipped:), and SHALL still block until the results are written.- Both nodes SHALL carry their own
phase, so a work item's ticket label saysloop:test-planning/loop:verificationrather than hiding the state inside a neighbouring phase. - The plan's content rules — which testing types are candidates,
n/awith a reason, the declared-not-managed verification environment, committed and redacted evidence — live inreference/testing.mdand the bundled template, not in the graph. The graph gates the shape; the reviewer judges the content.
The human gate
- A human review/approval step SHALL be a node, not a hook. It lasts days rather than milliseconds, receives events while it waits, runs an internal iterate-until-approved loop, and locks the artifacts it approves — none of which a hook's request/response shape can carry alone.
- A gate node SHALL default to
session: inherit: it reuses the harness session of the node that produced the artifact, so the reviewer's questions land in the context that wrote the thing. WHEN that session is gone THEN the gate SHALL fall back to a fresh session seeded with the checked-in artifacts — which is exactly the property decision-027 already relies on.
Two call planes
- Control plane — the-loop's own calls to external services (labels, comments, notifications) SHALL go through declared integrations whose transport is configurable (
integrations.<provider>.transport: auto|api|cli|sdk), so an operator picks what suits their environment rather than inheriting the-loop's taste. - Work plane — calls the agent makes while doing the work SHALL be unconstrained: CLI, MCP or API, whatever the task needs. Constraining the work plane would be constraining the engineer.
- Integrations SHALL declare their capabilities and SHALL fail closed at load time when a configured transport cannot serve a required operation, rather than at the moment a gate needs it.
- WHEN a service is reachable only over MCP THEN the-loop SHALL reach it by delegation to the harness (
mcp-call), because MCP is an agent protocol, not a daemon protocol (decision-042). - Jira (issue-475, decision-142). WHEN
integrations.jirais configured AND its credential variables are set THENresolve("jira")SHALL return the Jira provider, which runs every operation the GitHub provider runs, with the same result shapes, plustransition. It SHALL reach Jira through onejiraapi.JiraClienton the pycontribsjiraSDK: REST v3 with ADF on Cloud, REST v2 with wiki markup on Data Center.create-labelSHALL be a no-op that says so ("jira creates labels on use"), andset-labelsSHALL add labels while keeping the others.- WHEN
transitionis asked to close a ticket (to: done) THEN it SHALL takeintegrations.jira.closeTransition, else the one available transition into the Done status category. IF there are none or several THEN it SHALL fail listing the available transitions and SHALL NOT transition. - WHEN a ref names another site, or a project not under
integrations.jira.projects, THEN the provider SHALL refuse it before any request, so no credential is sent. - IF a credential variable is unset THEN
resolve("jira")SHALL raiseTransportUnavailablenaming the variable, andthe-loop doctor jiraSHALL report the same finding. A retiredclitransport SHALL be refused by name. - Credentials SHALL be read from the environment at call time and SHALL never appear in an error message or a log line: errors are built from the server's text and scrubbed, and the SDK's loggers carry a redaction filter.
- The provider joins the shared contract suite (
test_integration_contract.py). The graph's hooks still resolvegithubuntil PR 3 of issue-475 routes them by the work item's provider.
Which ticket a control-plane call reaches (issue-194)
Every control-plane call needs a work-item ref (github:OWNER/REPO#N), and a graph verb is not required to be given one. The ref SHALL be resolved in three tiers, and nothing outside them SHALL be invented:
- An explicit
--ref(orref=argument) SHALL always win. - Otherwise the ref SHALL be derived from the origin repository plus the work-item id:
issue-<n>in<owner>/<repo>yieldsgithub:<owner>/<repo>#<n>. The origin is the work item's own repository when the daemon builds the runtime, else the checkout'soriginremote (issue-352;ticketing.githubuntil then). This is the inverse of the ingress's own ref → id translation, and the two SHALL agree. - Otherwise the bare work-item id SHALL be used, exactly as before derivation existed.
- WHEN the work-item id is not
issue-<n>, OR the config declares no owner/repo pair, OR either name is not a shape GitHub accepts, THEN the-loop SHALL derive nothing. A ref pointing at the wrong repository is worse than no ref, so "no ref" is the fail-closed direction. - Before this, an omitted
--refreached the integrations as the bare id, where every operation raisedmalformed work item ref— so a work item parked atphase-selectionwith the checklist never posted, the phase label never set, and a cleanwaiton stdout.
Degraded side effects are reported, never swallowed
Outbound hooks are best-effort: a GitHub outage records and continues rather than wedging the graph (R6.12). That is unchanged. What changed is that the record now has a reader.
- WHEN a hook returns
passcarrying a non-emptyerror, THEN the runtime SHALL append one message naming the hook and the error to theNodeReportthatadvance,startandcleanupreturn, and SHALL emit awarning-levelgraph.hook_degradedevent — the CLI surface for a human at a terminal, the event log for the daemon's operator. - The node's status, its outcome, the edge taken and the pointer SHALL be exactly what they would have been. Surfacing a degradation SHALL NOT turn a passing chain into a blocked or parked one.
- A hook that declines to act SHALL NOT be reported:
post-phase-selectionfinding its own marker returnsposted=Falsewith a reason and no error, and that is idempotency, not a failure. - WHEN
graph forceorgraph skipcannot post its audit comment, THEN the verb SHALL take effect and the failure SHALL appear in its result'swarnings, which the CLI prints.
What drives the graph (issue-113, issue-148)
- The graph SHALL be driven by the ingress, not only by a human at a terminal: the shared dispatcher — which both the webhook receiver and the poller feed — SHALL enter a work item's start node when a session is spawned for it (every spawn enters — issue-148 closed the gap where tmux-hosted spawns never entered), and SHALL advance it at most one node boundary when an event is delivered to an existing session. WHEN the start node a spawn enters is a human node THEN its exit chain SHALL be evaluated once, with the spawning event's comments attached (issue-199): the comment that spawns a session is often the one that answers the gate it lands on — the arming
the-loop contributecarries the goal — and the control path consumes it rather than forwarding it, so no later event can deliver it. A contribution that states its goal up front SHALL therefore reachphase-selectionwithout a second command. An agent start node SHALL NOT be evaluated at spawn (its chain reads artifacts the session has not been given a chance to write), and a respawn SHALL evaluate nothing: only an entry that actually moved the pointer. - The session drives it too (issue-148):
the-loop graph complete <id>is the node-completion claim. WHEN a claim arrives THEN the runtime SHALL evaluate the current node's exit chain and advance only when it passes; the claim SHALL carry no verdict and no event text. Claims name their node: a replay of a claim for a node the pointer has left SHALL be a recorded no-op, a claim for a node that is neither current nor past SHALL be refused naming the current node, and a claim on an item that never entered the graph SHALL be refused. Output is one JSON envelope; a refusal or block is a result, not a CLI error. Claims are recorded in the state'scompletionsledger. - No verb writes configuration into a repository (issue-352, retiring issue-193's adoption). The CLI reads no harness config, so it has none to plant:
graph complete,graph advance,graph force,graph skip,check,graph statusandgraph showall leave.the-loop/exactly as they found it. The check operation stays pure by contract. - Work-item state is resolved before anything is delivered (issue-148): the dispatcher SHALL resolve a read-only context — current node, phase, status, parked/blocked reason, gate messages, the node's
command— before rendering any prompt, and SHALL render it into the$graph_contextplaceholder. A spawn prompt for a mid-graph item SHALL say resume at the current node; entering the graph (on_spawn, the write) SHALL still happen only after a successful spawn. Reads before the spawn, writes after it. - A work item that has not been placed yet SHALL still get a block (issue-273). Because the read precedes the write, a fresh work item has no pointer at the one moment the auto-execute prompt is written — and an empty block is what let a spawned session walk straight into
requirements-definition. The context SHALL then name the graph'sstartnode with statuspending, and the rendered block SHALL say the node has not been entered, that the session must not begin a phase, write an artifact, set a label or open a pull request, that the loop delivers the node's assignment when it enters it, and — when that node is a human node — that it is a gate for an authorized person and not the session's to answer or claim. It SHALL also tell the session to say so on the work item if no assignment arrives: an event prompt can carry a pending context too (a session predating the coupling, or one whose entry faulted), and there no assignment is coming, so the block must turn that into an escalation rather than a silent wait. Apendingcontext is deliberately not a waiting human gate: nothing has been entered, so no event is handed to a gate on its account, and the consult-first ordering above is unchanged. Where there is no graph to read at all the block SHALL stay empty, so a deployment withrouting.graph.enabled: falserenders exactly what it did before. - A waiting human gate sees its input first (issue-148): WHEN an event arrives for an item parked at a human-actor node THEN the dispatcher SHALL run
advance(with the event's comments) before delivering, and the delivered prompt SHALL carry the gate's verdict; the graph SHALL NOT be advanced a second time for the same event. There are no consume-only routes: every event is still delivered — a gate speaks first, never instead. WHEN the gate cannot classify (unauthorized author, indecisive text, fault) THEN the event SHALL still be delivered with the gate still waiting. - Advancement fails closed; delivery fails open (issue-148). No input — comment text, completion claim, payload — moves the pointer except through an exit chain over checked-in artifacts or
classify-feedbackon an authorized author's text. Any consultation fault delivers with the context unknown and recordsgraph.link_failed. - Work-item state has two writers (the daemon's link and the session's claim), so the load→mutate→save window SHALL run under an advisory lock (
work-item-state.lock, stdlibfcntl, no-op where unavailable); a busy lock reportsbusyrather than blocking, and a lost update on the no-op fallback costs a re-evaluation, never a wrong pointer. - WHEN a human gate is entered THEN the runtime SHALL resolve its session per
session: inherit— the bindingon_spawnrecords (session id, runner — always"tmux"since issue-156 — alive), flipped dead on close, re-recorded on respawn — and SHALL record the resolution asgraph.gate_session(inheritedorfresh-with-artifacts). The registry remains the dispatch authority. - Entering the start node SHALL run its entry chain, which is what writes the
loop:<phase>label. Before this, no node was ever entered on the automated path, so that side effect never fired and the phase labels stayed unpopulated. - An inbound event's comments SHALL be passed to the exit chain as
HookContext.event, so a human-approval node'sclassify-feedbackclassifies the reply that just arrived. The link SHALL always pass a comment's author alongside its body and SHALL NOT filter byauthorizedUsersitself — that decision belongs toclassify-feedbackalone, so there is exactly one place where it can be got wrong. - The coupling SHALL be best-effort and non-blocking: any failure is logged as
graph.link_failedand the event is still delivered. A hook that raises, hangs on a subprocess or fails an outbound call SHALL never cost a session spawn or a forwarded comment. - The coupling SHALL skip — leaving the graph exactly where it is — when it is disabled (
routing.graph.enabled: false), when the ref has no known spec-id convention, when the checkout is not the work item's own repository, or whencontrol.requireStartCommandholds and nobody has started the item. No input can move a work item forward; inputs can only cause a move not to happen. - Starting a graph SHALL NOT require a spec directory (issue-273).
<specDir>/<id>/is created by the work the graph exists to gate, so requiring it before the graph may start is a chicken-and-egg for every work item that begins life as a plain ticket — and the node it holds back isphase-selection, the outer loop's onerequired: truenode, whose entire job is to run before any spec exists. Thestartandcontextactions SHALL therefore proceed without it:startwriteswork-item-state.json(creating the directory as it goes) andcontextis a pure read.advanceandcleanSHALL keep the requirement — neither can be the first thing that happens to a work item, so for them a missing directory still means the graph was never placed here, and keeping it there is what preserves the rule above. - Where the specs are is the operator's CLI config's to declare (issue-352, decision-123, superseding issue-123's repository read). On the ingress path the coupling SHALL resolve the spec directory from
routing.graph.specDir(defaultdocs/specs) — one value for every checkout the instance drives — and in-sessioncheck/graphSHALL take--spec-dir, else the same key, else the default. It SHALL resolve that directory once and use the same value for the skip decision and for the runtime it builds, so the directory gated on and the directorywork-item-state.jsonis written into cannot drift apart. - That read SHALL happen only after
_checkout_belongs_tohas proved via theoriginremote that the directory is the work item's own repository, and a declared value that is absolute or resolves outside the checkout SHALL be refused — a value read from a repository must not select a write target elsewhere on the operator's machine. - A skip for want of a spec directory SHALL be recorded as
graph.skippedin the event log (work_item,action,reason,spec_dir). A work item that is labelled, armed and delivered to but whose graph never moves is a questionthe-loop eventsmust be able to answer; atlogger.debugit could not. Since issue-273 theactionon such a record can only beadvanceorclean. - A chain's routing outcome SHALL come from the last hook that declared one explicitly, whether or not that hook blocked. A gate that classifies a review returns
passcarrying its verdict, so reading the outcome only from a blocking result discarded it and parked every human-approval node withno_edge.
State, recovery and the escape hatch
- Work-item state SHALL be a cache, not an authority.
the-loop check --recomputeSHALL ignore stored state and derive each node's verdict from the checked-in artifacts alone, which is what makes the CI gate meaningful and drift discoverable. the-loop graph force --to <node> --reason <why>SHALL move a work item past its gates, exercisable by the authorized user running the-loop's CLI. It SHALL require a reason, SHALL record the override in three places (work-item state, event log, and a marked ticket comment), and SHALL warn about every gate it bypassed.- A force moves the pointer. It never forges a verdict. A bypassed gate keeps its real result, so
the-loop check --recomputestill reports it unmet after the force. An escape hatch that could rewrite history would be a way to launder an unmet gate into a met one. - Every transition, every non-
passhook and every edge taken SHALL be recorded in the structured event log (graph.*event types; see observability). - A read names the state file it read (issue-396). Every
checkreport — the CLI'sgraph status/check,POST /graph/check, thecheck_work_itemMCP tool — SHALL carrystatePath(thework-item-state.jsonit was read from, else the path looked for) andstateFound, and the CLI SHALL print them as onestate:line, found or not: a report that fell back to the graph's start node is otherwise indistinguishable from a work item that sits there. The position-unknown answer of issue-238 keeps its six keys and names no path. Every graph verb SHALL accept the work item's ref (github:OWNER/REPO#n) as well as its id, translated to the sameissue-<n>directory the daemon writes (graphlink.spec_id_for). WHENgraph statusorcheckis run without--repofrom a directory that does not hold the work item, AND the session registry records a session for that ref whosecwdis a directory, THEN the CLI SHALL report on that checkout — where the daemon's runtime wrote the state — and SHALL printrepo: <checkout> (from the session registry); a given--repoSHALL be used verbatim, and the mutating verbs SHALL keep the working directory. - An ended work item is reported from its archive, not from the caller's directory (issue-452). WHEN
check(andgraph status,POST /graph/check, thecheck_work_itemMCP tool) is given a work item's ref, finds nowork-item-state.jsonwhere it looked, AND the ref's portable record carries anendedstamp, THEN the report SHALL carryarchived— the closure'soutcome(completed | cancelled | closed-externally | unknown), itsstate,reason,atandactor,detail(recorded | unavailable) and theterminalrecord when there is one — SHALL list no node findings, SHALL setcurrentNode/pointerto the recorded node (""without a record) and SHALL beokonly whencompleted. The CLI prints<id>: ARCHIVED — <outcome>. A found state file SHALL always win;--recomputeover a spec directory that is present SHALL still evaluate it; a bare id SHALL NOT consult the archive. The terminal record is a filtered copy of an agent-writable file and SHALL NOT be read back into routing or a gate. Before, normal cleanup made a delivered item read asphase-selectionwith the full process "missing". - The stop gate SHALL NOT ask about a node the work item never entered (issue-429). Every
checkreport SHALL carrypointer— the node work-item state records,""when no state file was found — besidecurrentNode, which under--recomputeis the first node the artifacts leave unmet and can run ahead of the pointer. The harness stop gate (hooks/the-loop-gate.py) SHALL take every verdict from the recomputed report and SHALL block only on the first unsatisfied node at or before the pointer, and only when that node isblock. A report with nocurrentNode, an emptypointer, or apointerthat names no node in the report SHALL be inconclusive: the turn ends. The position is the one fact the gate takes from agent-writable state; moving the pointer forward hides nothing (the first unmet node before it is still the finding), and CI'scheck --recompute— which reads no pointer — stays the backstop. Thechecktable under--recomputeSHALL name the pointer on itsstate:line when the two differ. - A gate that evaluated nothing SHALL NOT pass (issue-238). Since the control-plane API answers a
repothat does not resolve with a position-unknown report rather than an error — a checkout somebody cleaned up is expected state on that machine, see control-plane — such a report carries no nodes, and therefore no blocking node.the-loop checkSHALL fail it in both--fail-onmodes, ahead of either rule, and SHALL sayUNREAD — <path> is not a directoryrather than render it as a work item with no phases. Otherwise a mistyped--repowould take--fail-on block, the automated-gate mode, straight to exit 0.
Design
docs/specs/issue-109/design.md · decision-041 · decision-042 · reference/workflow.md · architecture
History
| Work item | What changed | Links |
|---|---|---|
| issue-488 | The explainer and the two quizzes (2026-10-15): four skippable nodes — spec-explainer → spec-quiz after design-approval, code-explainer after security-review, code-quiz before human-approval — behind one ticked explainer setting row and skip set; post-quiz-attestation / quiz-attested hooks; the comment-edits integration op over GraphQL userContentEdits; the poller's tick detection and the webhook's edited rewrite into a delivery-only quiz_attestation doorbell; not-offered skips for nodes a frozen selection never listed; hardened in review: a quiz comment counts only when an authorized login (or Jira's service account) wrote it, every editor in its history must be authorized, ticks are deduplicated per tick, and explainer-postdates-spec enforces R5.6 at publish | spec, decision-143, issue |
| issue-475 | Jira joins the control plane (2026-10-06, PR 2): jiraapi.JiraClient on the pycontribs jira SDK (REST v3/ADF on Cloud, v2/wiki on Data Center, credentials read from env vars at call time, errors and SDK logs scrubbed), JiraProvider with GitHub's operations plus transition in the contract suite, a jira branch in resolve(), the integrations.jira block at config 0.12.0 with a migration from the stub (a stub with no site or projects is dropped whole, with a note — it could never pass the schema), and the-loop doctor jira | spec, decision-142, issue |
| issue-472 | The phase-selection comment is laid out to be skimmed (2026-10-06): a titled question and a quick start first, then the phases and the non-phase settings as two groups, a heading per question opened by an emoji of its own, each setting's default stated before its rows, and every explanation collapsed under <details>. The rows, and so the reply parser and the Slack mirror, are unchanged; the Slack digest unfolds <details> and draws a <summary> bold. With every section offered, the visible text drops from 989 to 554 words | spec, issue |
| issue-471 | phase-selection offers the spec chain as one Claude artifact (2026-10-06): a non-phase claude-artifact row, unticked, offered wherever the surface row is. The signed reply freezes claudeArtifact into work-item-state.json, the decision record, the frozen graph and the archive — true only when ticked and the work item resolves to the claude harness (or one the gate cannot know); a tick on another harness is not applied and the confirmation names it. The outer loop's graph-context block then carries one fixed line, and the skill carries the procedure. The files stay what every gate reads; an artifact comment never answers a gate. The Slack mirror keeps the row as a non-phase row | spec, spec-workflow, issue |
| issue-452 | A completed work item stays completed after normal cleanup (2026-10-02): core.graphs.check answers a ref whose state file is not found from the portable record's ended stamp — archived with the outcome and the terminal record the closure kept, no node findings, ok only for completed; the CLI prints ARCHIVED — <outcome> (or that the detail is unavailable). Before, check github:…#3 from the deployment directory after keepCheckoutOnClose: false cleanup reported phase-selection, ok: false and the full process missing | spec, webhook-triggers, issue |
| issue-429 | The stop gate stops demanding a phase the work item never entered (2026-09-28): the check report carries pointer (the node work-item state records) beside currentNode, and the-loop-gate.py blocks only on the first unmet node at or before it — inconclusive when there is no position. Before, a work item parked at phase-selection with its selection recorded was told on every turn to write design.md, because --recompute places currentNode at the first node the artifacts leave unmet | spec, issue |
| issue-343 | Graphs of the operator's own (2026-09-23): the top-level graphs in the CLI config declares a YAML file, and routing.control.commands binds the commands that select it — a re-pointed start/contribute/do/review, or a new word that parses as start with a loop. The control record keeps the loop; resolve_outer_loop accepts a declared name and nothing else; a custom graph is compiled by the shipped compiler, held to PHASE_VOCABULARY, and may name the operator's x- hooks; a node's command: is grammar-checked in every graph and may name another plugin's slash command; attachments may be scoped with loops; the-loop graph loops lists and checks every loop | spec, decision-136, issue |
| issue-396 | graph status reads the state file the runtime wrote, and says which (2026-09-20, B7/O6 of the e2e run): every graph verb accepts a work-item ref and translates it to the daemon's issue-<n> directory (core.graphs.work_item_id, on graphlink.spec_id_for); the check report carries statePath/stateFound and the CLI prints a state: line, found or not; graph status/check with no --repo and a ref the working directory does not hold resolve the session's checkout through the session registry and say so (repo: … (from the session registry)). Before, graph status github:…#1 addressed docs/specs/github:…#1/ — never there — and reported the graph's start node in silence | spec, issue |
| issue-378 | The runtime publishes phase.started / phase.completed on the channel bus at every transition of every graph (2026-09-18), following the label rather than the node: WorkItemState.phase records the phase the walk is in, an approval node inherits its author node's phase, a force publishes nothing, cleanup starts its phase and completes none. Nothing about the graphs' YAML, the verdicts or the pointer changed | spec, decision-130, channels, issue |
| issue-370 (owner ruling) | A pull request armed as its own work item is recorded in its pullRequests[] too (2026-09-16), marked self: true and carrying no stateDir — the work item's own directory is the loop, so the derived inner-loop path would nest it inside itself. the-loop review and the-loop contribute are armed on a pull request, and the entity the-loop manages is a work item whatever represents it, so both relations a pull request can have to a work item live in one list and the marker says which | spec, PR #372, issue |
| issue-370 | pullRequests[] got one writer (2026-09-16): the-loop sessions link-pr, run by the session that opened the pull request. The first event that routed for a pull request used to write there too, as linkedBy: "event" — so an issue-<n> branch in a fork, or a fixes #<n> in a body, added a row to a committed file for a pull request nobody on the work item had heard of. A row from before the change keeps its "event" provenance, read and never rewritten. The linked-pulls integration op went with it: a work-item review's scope is pre-filled from what the-loop recorded, then its pr-loops/ state, and GitHub is not asked | spec, webhook-triggers, issue |
| issue-368 | The work item's own file gained what the repository may know, and lost what it may not (2026-09-15): sessionPerPr, model and effort moved in from the portable record's now-retired graph section, pullRequests[] records each pull request delivering the item (ref, repository, number, url, inner-loop directory, upstream state, provenance), and the session block left — a harness conversation id is a handle to one machine, so session: inherit resolves through that machine's session registry and falls back to a fresh session seeded with the artifacts where there is none (CI). A legacy session block is ignored on read and dropped on the next save; it is never resumed | spec, decision-128, cli, issue |
| issue-365 (review) | graph-state.json became work-item-state.json (2026-09-14): the name was the narrowest true description of a file that had carried the surface, the session, the PR-session mode, the model and the effort for releases. Read under the old name, written under the new one, and the inner-loop scan globs both — a work item mid-flight keeps its pointer with no migration step. the-loop graph repos arrived with it: the agent declares which repositories the work item raises pull requests in, once the design and the task DAG say what the change spans, refused for a value that is not a repository path or is outside the instance's own repositories — and await-inner-loops reads it there | spec, decision-127, spec-workflow, issue |
| issue-365 | The six review-chain gates changed subject (2026-09-14): validates: execution-log.md became one evidence/<record>.md per node, verification's kept gate points at evidence/verification.md (produced by no node, so never a planned absence), design-critic-review at evidence/design-critic-review.md, and the log-entry hook is gone from the registry and from all five shipped graphs. One file per gate, never one shared record: a gate must not be satisfiable by the round another node ran | spec, decision-126, spec-workflow, issue |
| issue-440 | phase-selection asks a third per-work-item question — which harness — as a harness-<name> section offered only when two or more declared harnesses can host a session (harness.hosts_sessions, derived from the adapter's interactive_argv). Resolved before the model and effort, which are then checked against it; frozen as harness beside them. The gate and the daemon now share one default-harness rule | spec, interactive-sessions, issue |
| issue-358 | phase-selection grew two more per-work-item questions — which model, and at what effort — resolved per section against what this work item's harness can actually run, and frozen beside surface/sessionPerPr. The spawn also moved to after the gate: graphlink.on_arm enters the graph when a work item is armed and reports whether the pointer parked on a human start node, and the dispatcher spawns nothing while it has | spec, decision-124, interactive-sessions, issue |
| issue-352 | The graph stopped reading the harness config (2026-09-12): build_runtime takes the spec directory from the CLI config or --spec-dir, the phase label prefix is the constant loop:, the origin repository is the work item's ref or the checkout's origin remote, repoInitialized became guestLoop (a contribution or review keeps its spec tree out of git and posts its plan to the thread; the work item's own loops never do), notify reads roles from the node's with: only, and the operator's hooks come from routing.graph.hooks (load_graph(declaration=…), repoHooks gone). Adoption (issue-193/201) is retired: no verb writes into .the-loop/. workflow.phases and its parity test are gone — the graph is the only phase list. The stage keys nodes declare are matched against the token-economy guidance's stage table, not against a tokenEconomy routing map — that block left the harness config too | spec, decision-123, issue |
| issue-281 | The gate became the locker (2026-08-25): validate-artifacts stopped demanding locked: true on any producing node — brainstorming, requirements-definition, design, test-planning, tasks-breakdown, and the contribution loop's scoped-plan gate shape only — and a new lock-artifacts hook on the approval nodes' exit chains (after classify-feedback and record-feedback) writes status: approved and merges the approving authors into approvedBy as a comment-preserving front-matter splice, verified after the write and failing closed. It consumes the classifier's verdict from the same chain run (never re-reading comments), skips on changes-requested or an absent artifact, and declares no outcome, so the classifier alone routes. This ends the double-ask the stacked layers produced: one human approval per gate, and no approval at all for nodes the graph gives no gate | spec, spec-workflow, issue |
| issue-279 | A fifth shipped loop, pdlc-review-loop (2026-08-24): the-loop as a pull request's reviewer, never its author. Armed by a ninth control keyword (the-loop review) that — alone among the keywords — binds to the pull request itself rather than its linked ticket; gated by a required: true brief gate (review-brief posts a fill-in template, freezes an authorized reviewer's questions/angles/validations with provenance); each agent round answers the frozen brief in one self-marked comment; the follow-up gate reuses classify-adhoc-reply, so any authorized reply that is not "done" is another round. No produces, no phase-selection, no code changed by contract, and no adoption — the contribution loop's guest carve-out generalized to GUEST_LOOPS | spec, decision-101, issue |
| issue-273 | phase-selection stopped being routed around by default (2026-08-20): the ingress→graph coupling gated every graph action on <specDir>/<id>/ already existing, so a work item minted as a plain ticket — no /create-ticket, no committed spec folder — had its graph declined at spawn (graph.skipped, no-spec-dir, twice) and its session walked into requirements-definition with the outer loop's one required: true node never having run: no checklist, no the-loop execute, no frozen graph, and nothing for the-loop check to attribute. start and context are now exempt (the directory is created by the work the gate holds back; advance and clean keep the check), and the read-before-spawn context of an unplaced work item renders its start node as pending — a block that names the gate and forbids beginning a phase before the node's assignment arrives, instead of the empty block that let the session start on its own | spec, webhook-triggers, issue |
| issue-247 | The one hook that writes markdown into a checked-in artifact stopped writing markdown the project's own linter rejects (2026-08-16): record-feedback's attribution gained trailing text (**@handle** wrote:), because emphasis alone on a line is precisely MD036's target — so every approval-with-comments had been leaving design.md and testing-plan.md failing make lint, and a session hand-editing the paper trail to unblock itself. A comment with an empty body now becomes one attribution line instead of a blank-line run (MD012). The reviewer's body stays verbatim, which is the other half of the rule: the harness fixes its own markdown and never a human's words, so a body that fails lint on its own merits is out of scope by decision rather than by omission | spec, decision-089, issue |
| issue-238 | A gate that read nothing stopped counting as a gate that passed: when graph/check began answering a non-resolving repo with a position-unknown report instead of raising, that report had no nodes and so no blocking node, and the-loop check --fail-on block — the automated-gate mode — would have exited 0 on a mistyped --repo. Both modes now refuse it ahead of either rule, and the row renders UNREAD — <path> is not a directory instead of UNMET (at ) with nothing under it. Found by self-review of the control-plane fix, not by the fix's own tests | spec, control-plane, issue |
| issue-260 | How many sessions a work item's pull requests get moved from the operator to the work item (2026-08-17): issue-258 gave the choice to routing.tmux.sessionPerPr, machine-wide — the same mistake issue-183 refused to make for outer-loop-on-pull-request, because one repository has both a one-repo bugfix and a three-repo migration and one daemon serves both. phase-selection now carries three rows (pr-sessions-never / pr-sessions-cross-repository / pr-sessions-always) with the deployment's configured value pre-ticked; exactly one ticked row is the choice, and none, several, an unreadable checklist or a token outside the vocabulary all resolve to that default. The resolved mode is frozen by the same signed the-loop execute into work-item-state.json and the portable record (graph.sessionPerPr), and routing reads it there per work item ahead of the config key. Nothing else moved: the three modes mean what decision-092 said, decision-088 D2's tree requirement is untouched, the schema is unchanged, and a work item with no frozen mode routes exactly as before | spec, decision-093, routing, issue |
| issue-225 | The ad-hoc loop (2026-08-14): a fourth shipped graph, pdlc-adhoc-loop, walked when a requester wants a tactical task done and no PDLC process run — armed by the new do control keyword (routing.control.keywords.do), driven by /the-loop:do-task. Three walkable nodes (work -> review -> complete, with review routing back to work on more-work) and deliberately no goal-definition, no phase-selection, no produces/validate-artifacts, no skipSets and no review chain — the issue-177/179 attribution invariant holding by construction, because nothing is skipped when the loop declares nothing to skip. A new classify-adhoc-reply hook inverts the review gate's default (any authorized reply that is not a declaration of completion is more work; the newest comment decides; self-authored and unauthorized text is never read), and graph.model.resolve_outer_loop becomes the single fail-closed decision point the three copied "contribution or default" comparisons used to be | spec, decision-083, webhook-triggers, issue |
| issue-199 | A contribution is not asked where its outer loop goes, and does not wait for a second command (2026-08-10): pdlc-contribution-loop joins somebody else's in-progress work item and owns no outer loop, so phase-selection omits the outer-loop-on-pull-request row for it, ignores the token if a reply carries it, confirms no surface, and freezes an empty one — never asked, not default kept — with the session prompt placing the work on the thread instead of naming an outer loop; and a spawn now evaluates a human start node once with the spawning event attached, so the goal that rode in with the-loop contribute moves the item to phase-selection on its own (agent start nodes and respawns evaluate nothing) | spec, webhook-triggers, issue |
| issue-194 | Outbound hooks stopped being dead and silent (2026-08-10): a graph verb with no --ref had been handing the bare work-item id to the integrations, where every operation raised malformed work item ref — so nothing was posted, no label was set, and the command printed a clean answer. The ref is now derived from ticketing.github plus the issue-<n> id (a new graph/refs.py, the inverse of the ingress's spec_id_for, refusing anything that does not validate rather than guessing), and a best-effort hook that records an error while passing is reported as a warning line on the NodeReport plus a graph.hook_degraded event — without changing any node's verdict or edge. graph force/graph skip report a failed audit comment in their warnings; _split_ref's error names both remedies; sideeffects.py resolves its integration at call time, so the seam every test patches finally applies to it | spec, cli, issue |
| issue-193 | An unconfigured repository is adopted, and a guest still is not (2026-08-10): the four state-changing graph verbs (complete, advance, force, skip) write the-loop's built-in default harness config into a repository carrying none, before the runtime is built — so repoInitialized is true on the very run that adopted it — while check/status/show write nothing, and pdlc-contribution-loop adopts nothing at all, keeping issue-185's spec-tree exclusion and thread publishing pointed at the repositories they were written for | spec, decision-073, webhook-triggers, issue |
| issue-188 | Opt-in phases, and the design critic round (2026-08-10): a second node marker, optIn: true — the mirror of skippable, implying it (same vocabulary, same on: skipped edge, same provenance) but off unless an authorized human ticks it at phase-selection; required×optIn and an opt-in skipSets member refused at compile time; a node description rendered beside its checklist row; selections recorded as optIns in work-item-state.json (graph.opt_ins_selected), filtered through the compiled graph on every read, carried into the frozen graph per node, and named in the confirmation comment; an unselected opt-in node routed around and reported by check as not selected — never as a declaration, never as a pass — which also leaves every pre-issue-188 work item unblocked; the outer loop ships one such phase, design-critic-review, between design and test-planning | spec, decision-071, review-loop, issue |
| issue-186 | A terminal cleanup node in both work-item-level loops (2026-08-10): the-loop enters it — via Runtime.cleanup, a sibling of start rather than a force — immediately before releasing a work item's local resources, so the teardown carries a loop:cleanup label and an execution-log checkpoint. No inbound edge (complete stays terminal), none in pdlc-pr-loop, and the one graph action exempt from the start requirement | spec, interactive-sessions, issue |
| issue-185 | The contribution loop (2026-08-09): a third shipped graph, pdlc-contribution-loop, walked when the-loop is invited into an existing, in-progress work item as a contributor — armed by the new contribute control keyword (a spawn-arming sibling of start, routing.control.keywords.contribute); a required goal-definition gate (post-goal-request/classify-goal hooks) that refuses to start until an authorized human states a goal and success criteria, frozen into work-item state with provenance; one lightweight contribution.md artifact (bundled template) in place of the four-file spec chain; verification gating on every criterion checkbox being met; GraphState.loop recording which loop a state walks, resolved state-first everywhere with non-shipped names failing closed to the default | spec, decision-070, webhook-triggers, issue |
| issue-183 | Multi-repo topology named (2026-08-09): the outer loop runs in the repository the ticket was created in and each contributing repository gets one PR and one inner loop, whose state is qualified by repository (pr-loops/<owner>__<repo>/pr-<n>/) with the origin repo's shipped path unchanged; repository names are validated at the path boundary, never sanitized; a qualified cross-repo closing reference now routes to its work item; execution-log.md front matter takes repos: and await-inner-loops holds implementation until each declared repository has a finished loop (blocking on a malformed entry); the surface the OUTER loop is collaborated on became a per-work-item choice at phase-selection (one extra checklist row, frozen by the same signed reply; default: the work item itself), deliberately not a config key anywhere, with the inner loop not configurable at all; graph verbs and the API gained --pr-repo/prRepo | spec, decision-069, spec-workflow, issue |
| issue-179 | Every phase is selectable (2026-08-08): the outer loop's skip vocabulary widened from the spec chain to every node it walks except phase-selection (which keeps required: true and is now the whole floor — the loop cannot walk past the act of choosing) and the terminals; security-review and human-approval traded their required markers to become declarable; ten new on: skipped edges and a second shipped set, review-chain, beside a spec-chain that now includes test-planning; validate-artifacts gained onlyWhenSkipped: so a kept gate keeps a subject — verification gates the execution log's Verification results when the plan was declared away, and blocks until it is written; the phase-selection checklist says what an empty protected list means | spec, decision-068, issue |
| issue-177 | Declared skips (2026-08-08): skippable: true fixes the vocabulary in the shipped graph (spec-chain nodes only; compile-refused on required nodes, on missing skipped edges, and on skipSets members outside it); the outer loop gained a first human node phase-selection where the-loop posts a phase checklist, the user ticks it in place, and an authorized the-loop execute (a routing.control command) freezes the selection — the resolved graph landing in both work-item-state.json and the portable session record (the audited graph skip verb is the same declaration from a shell); the runtime routes around declared nodes without running their hooks and check reports them as skipped by declaration with provenance — never a pass; a forged declaration on a protected node is inert and surfaced; later gates treat a skipped author's absent artifact as planned; deliver-assignment announces a human gate instead of telling the session to claim it | spec, decision-067, issue |
| issue-174 | capability-docs gates two sections of the execution log instead of one — ## Documentation joins ## Capability docs, so a work item cannot complete having left the README or the docs site describing the process it replaced. No new node, no hook or runtime change; the inner loop gates neither | spec, decision-066, documentation, issue |
| issue-172 | The process became two named loops (2026-08-07): pdlc.yaml renamed to pdlc-work-item-loop.yaml (unchanged content, plus the await-inner-loops gate on implementation), and pdlc-pr-loop.yaml added — one inner loop per PR, run in that PR's own session with state under docs/specs/<id>/pr-loops/pr-<n>/, merge driving it to complete as an audited force. Graph verbs gained --pr; P5 parity asserts over both loops; deliver-assignment makes the graph the initiator — entering an agent node pushes its assignment into the bound session | spec, decision-065, webhook-triggers, issue |
| issue-167 | Six gates stopped reporting success without running: validate-artifacts gained validates: for an artifact a node asserts against but did not author, so the six review-chain nodes gate their sections of the shared execution-log.md; a content gate that resolves no artifact now blocks (not retriable) instead of skipping; the bundled execution-log template gained the Capability docs section capability-docs had always demanded; P5 asserts all three against the shipped graph | spec, decision-063, issue |
| issue-163 | Testing became two nodes: test-planning produces testing-plan.md before the task DAG that references it, verification re-gates the same artifact after implementation and before the review chain; a skip stopped short-circuiting a chain, which is what had left implementation parking at no_edge | spec, decision-060, testing-and-contracts, issue |
| issue-156 | Process runner removed; tmux is the only runner (2026-08-05): every spawn is tmux-hosted, so "every spawn enters the graph" no longer needs a per-runner qualifier, and the gate-session binding's runner is always "tmux" | spec, issue |
| issue-148 | The graph went from observer to authority: the-loop graph complete (the node-completion claim — idempotent, node-named, never a verdict), GraphContext resolved read-only before every delivery and spawn, the $graph_context prompt block, consult-first ordering at human gates (no consume-only routes), resolve_session gained its caller (graph.gate_session), tmux spawns finally enter the graph, two-writer state locking, and P4 phase parity — pdlc.yaml defines the sequence, the prose renders it | spec, issue |
| issue-124 | produces names an artifact rather than a filename: |-separated alternatives, one resolver shared by every hook that reads them, ambiguity fails closed, malformed entries fail at compile; enforces-boundaries-from resolves upstream the same way, which turned a security gate that had been silently skipping for every bug work item into one that runs; graph ↔ manifest ↔ template parity is now a test | spec, decision-045, issue |
| issue-123 | The daemon stopped taking specDir from the operator's machine: routing.graph.specDir defaults to unset, so the work item's own workflow.specDir wins (reversed in issue-352: the CLI reads no harness config and the key is gone; docs/specs is the convention); the gate and the runtime resolve one value; the checkout's ownership is proved before its config is read; an escaping value is refused; and the skip is recorded as graph.skipped instead of a debug line | spec, decision-044, issue |
| issue-113 | Wired the ingress to the graph: Runtime.start(), the GraphLink seam in the shared dispatcher, HookContext.event finally written, the routing.graph config block, and the chain-outcome fix that lets a passing gate's verdict reach its edges | spec, issue |
| issue-109 | Established the capability: the two-concept graph (node + hook), the HookResult contract, the shipped PDLC graph, ten hooks, configurable integration transports, the-loop check/graph, and the forced-transition escape hatch | spec, decision-041, decision-042, issue |