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 and pdlc-adhoc-loop.yaml), 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 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; a fifth,pdlc-project-management-loop, is 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 intograph-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 (in the execution log when 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. - The target repository need not have adopted the-loop (PR #187 review), and a contribution SHALL NOT adopt it (issue-193, decision-073): every other loop writes the built-in default into a repository that carries none, and this one deliberately does not — a guest does not install itself. WHEN it carries no
.the-loop/harness-config.yamlTHEN every harness-config read SHALL degrade to the built-in defaults (decision-044), 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 such a repository offers. In an adopted repository 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), so adopting it changes no repository'sworkflow.phases. - Unlike a contribution, an ad-hoc item is not a guest: an unconfigured checkout SHALL be adopted exactly as the outer loop adopts it (issue-193). The harness config is what supplies 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 —
- Every work-item-level loop ends at a
cleanupnode (issue-186).pdlc-work-item-loop,pdlc-contribution-loopandpdlc-adhoc-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>/graph-state.json— beside the outergraph-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 (
ticketing.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
execution-log.md's front matter declaresrepos: [<owner>/<repo>, …]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. - 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 tograph-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 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 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 graph SHALL be internal to the-loop: it ships as package data inside the CLI — the thing that executes it, and where every hook it names is registered — and a consuming repository does not define or override it. A repo-local
.the-loop/graph.yamlSHALL be ignored with a warning, so that user-authored graphs can be enabled later as a deliberate feature rather than arriving as an accidental one (R1.5). - 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 tograph-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
graph-state.jsonand pushed to the work item's portable session record (frozenGraph,graph.frozen), so what the loop will walk is a recorded fact rather than a comment anyone can keep editing, readable without a checkout. A failed publish SHALL be recorded (graph.frozen_publish_failed) and SHALL NOT gate the selection — the checked-in state file is the authoritative copy. - 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 gates the sharedexecution-log.mdfor a non-empty Verification results section instead, and blocks until it is written — skipping the plan removes the document, never the verifying.
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. - A selection SHALL be recorded in
graph-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 lockeddesign.mdagainst the requirements before the testing plan and task DAG derive from it, gating the execution log'sDesign critic reviewsection. 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 this node authored it. A node that gates an artifact it did not author — the six review-chain nodes each own one section of the shared execution-log.md — declares it on the hook entry instead (decision-063):
exit:
- {hook: validate-artifacts, with: {validates: execution-log.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 a public key in operators'tokenEconomy.modelRouting.stagesandthinkingEffort.stagesmaps, so a rename would drop their configuration silently. 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 · 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).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).
Testing is planned and verified as nodes (issue-163)
test-planningSHALL sit betweendesignanddesign-approvaland producetesting-plan.md, gating on the artifact being locked and carrying non-empty Test matrix, Verification environment, Evidence plan and Verification results sections. 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, and is still locked 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 gate the execution log'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-locked loop, and produces artifacts — none of which a hook's request/response shape can carry.
- 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).
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
ticketing.githubin the repository's harness config plus the work-item id:issue-<n>in a repository declaring<owner>/<repo>yieldsgithub:<owner>/<repo>#<n>. 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. - A state-changing verb adopts an unconfigured repository; a read never does (issue-193, decision-073). WHEN
graph complete,graph advance,graph forceorgraph skipruns in a repository carrying no harness config THEN the-loop SHALL write its built-in default there before building the runtime, so the verb and the daemon read one configuration instead of two sets of defaults. WHENcheck,graph statusorgraph showruns THEN nothing SHALL be written — the check operation is pure by contract, and asking a question must not dirty a CI checkout. A contribution adopts nothing, whichever verb is used. - Graph 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 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. - Graph 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 (
graph-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 and the execution-log checkpoint. Before this, no node was ever entered on the automated path, so those side effects 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 work item has no spec directory, 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. - Where the specs are is the work item's own repository's to declare (issue-123, decision-044). On the ingress path the coupling SHALL resolve the spec directory from the checkout's
workflow.specDir(defaultdocs/specs), withrouting.graph.specDirleft as a deliberate override for a checkout that carries no harness config — not, as before, as a machine-scoped default that silently governed every watched repository. 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 directorygraph-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 spawned but whose graph never moves is a questionthe-loop eventsmust be able to answer; atlogger.debugit could not. - 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
- Graph 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 four places (graph state, execution log, 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 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-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 graph-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 graph-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 graph 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 graph-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; 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 |