Capability: webhook-triggers
GitHub events (comments, reviews, CI results) — and, since issue-475, Jira comments — reach the right running harness session on the user's own machine, programmatically.
What it is
The local trigger path: an HMAC-verified webhook receiver plus event → session routing, so a PR comment or workflow result resumes the Claude Code / Cursor session working that item — the self-hosted equivalent of claude.ai/code PR watching.
Current behaviour
With a gateway (issue-496): the receiver and the poller run on the gateway under the same provider contracts (
POST /gh-webhookwithX-Hub-Signature-256, the same poll delivery ids), and an instance with agatewayblock hosts neither; its gateway client fetches the stored events and runs this same router and dispatcher on them, withgateway_event_idon everydispatch.*record. The router's PR→ticket linkage is published to the gateway as a binding. See gateway.
The receiver (started by
the-loop startperwebhooks.ghWebhook.enabled, or in the foreground bypython -m the_loop.daemon_entry gh-webhook) SHALL run an HTTP receiver (default127.0.0.1:8787, path/gh-webhook) that verifiesX-Hub-Signature-256HMAC usingTHE_LOOP_GH_WEBHOOK_SECRET, exposesGET /health, and logs events.WHEN
webhooks.ghWebhook.eventsis omitted or empty THEN the receiver SHALL accept the-loop's default event set —issues,issue_comment,pull_request,pull_request_review,pull_request_review_comment,workflow_run,check_run,check_suite,status, i.e. every event it can map to a work item. An explicit list narrows it, and WHEN that list omitsissuesorpull_requestTHEN the receiver SHALL warn at startup and on hot reload: a work item that ends would never be seen, so its session (and tmux session) would leak. A warning, not an error — narrowing is the operator's call.The receiver is bounded by the repositories the operator declared (issue-348, decision-121). WHEN the top-level
repositoriesis non-empty AND a delivery's own repository —repository.full_nameon the host itshtml_urlnames, compared as one lowercasedhost/owner/repokey — is not in it THEN the receiver SHALL drop the delivery, recordrouting.droppedwith reasonundeclared-repositoryand the repository, and dispatch nothing. WHEN a delivery names work items in repositories outside the list — the cross-repository linkage of issue-183 — THEN those refs SHALL be dropped and the rest kept; IF none remains THEN the delivery SHALL be dropped with the same reason. The check SHALL run above the actor guard and above the bus, so an undeclared repository's payload reaches neitherrouting.authorizedUsersnor a channel, and the dropped delivery SHALL NOT be marked processed, so a redelivery after the repository is declared still routes. The drop SHALL leave no mark: the same acknowledgement every delivery gets, no reaction, no comment, and no repository named on the wire.- WHEN
repositoriesis empty or absent THEN nothing is bounded — every delivery is judged exactly as it was in 13.12.0 — and the receiver SHALL say so once at start, naming the key. This is the one permissive direction in the declaration, and it exists so that an upgrade cannot silently stop an instance. - The bound is not authentication. It narrows what a forged or unsigned delivery can address; the signature (only checked when
secretEnvresolves) and the authorized-actor guard (decision-023) are unchanged, and so isinstance.scope, which answers which instance takes a work item rather than which repositories may reach this machine.
- WHEN
WHEN routing is enabled (
routing.enabled) THEN a verified event SHALL be matched to a registered session (.the-loop/sessions/*.json, managed bythe-loop sessions) and delivered into that session's tmux-hosted conversation (respawned first when it has died — see interactive-sessions), serialized per session and parallel across sessions (maxConcurrentDispatches).WHEN no session matches THEN the router SHALL spawn a new session per
spawnOnUnmatched(never | always | labeled, defaultlabeled— opt-in via the auto-execute labels) using the configured prompt templates — and, since issue-106, only once an authorized user has explicitly started the work item (see Execution control below).The arming gate is a set of labels, every one required (issue-381, decision-131).
routing.autoExecuteLabelsis a list (default["the-loop: auto-execute"], at least one entry); WHEN an issue or PR carries every label in it THEN the item SHALL count as labelled — on alabeledaction the label being added is read together with the item's label set, so the last missing label arms on the event that adds it — and WHEN it carries only some THEN it SHALL NOT, whichever entry is missing. An empty list arms nothing. The poll ingress SHALL list by the same set (polling.sources[].labels, empty = reuse the routing list): one--labelper label to GitHub's listing filter, and a returned item that does not carry every label SHALL be dropped before it is tracked, so both ingresses agree whatever the filter returns. The single-stringautoExecuteLabeland sourcelabelare refused rather than read — the shared default in place of a custom label would arm more than the operator set — andthe-loop migrate-configwraps them (config version0.10.0).A forwarded event carries the instruction, not GitHub's metadata (issue-243, decision-086). The
$payload_excerptblock is a field allow-list per container, not a subset of the raw payload — the same function for both ingresses, so a comment reads identically whether it was pushed or polled.Event What the excerpt carries issue_commentthe comment's body,html_url,authorpull_request_review_commentthe same, preceded by pathandlinepull_request_reviewstate,body,html_url,authorissues/pull_requestthe acting actor, the entity'snumber,title,state,html_url(plusdraft/mergedfor a PR), and thelabelname when the action islabeled/unlabeledworkflow_run/check_run/check_suitenamewhere the object has one,status,conclusion,head_branch, the URLs, and a check run's ownoutputtitle and summarystatusstate,context,description,target_url- WHEN a comment event is rendered THEN the excerpt SHALL carry no
senderoruserobject, noissueobject, and noapi.github.comURL: an author is a bare login, and the comment's own URL already names the issue or pull request it lives on. - WHEN a lifecycle event is rendered THEN the excerpt SHALL carry the entity's title but not its body: a spawned session's first act is
/the-loop:work-on <ref>, which reads the ticket itself, and the body was both the largest string in the excerpt and the largest attacker-controlled one — travelling with every event about that item, not only the spawn. - WHEN free text (
body,description, a check run'ssummary) is longer than the per-field cap THEN that field alone SHALL be truncated with a visible marker, the rendered excerpt SHALL remain parseable JSON, and the object'shtml_url— and an inline comment'spath/line, which are emitted first — SHALL survive it. - IF an event has no rule of its own (an operator's extra
webhooks.ghWebhook.eventsentry) THEN it SHALL be distilled over whichever known containers it carries, and SHALL never fall back to the raw payload. IF nothing is recognised THEN the excerpt SHALL render{}and the prompt SHALL otherwise be delivered unchanged: an unrecognised shape costs the session context, never the delivery of an event an authorized human already caused. - The excerpt is prompt text only. Routing,
authorizedUsers, the self-comment marker check, control-keyword parsing, reaction targeting and head-ref resolution all read the full payload and are unaffected by what it omits.
- WHEN a comment event is rendered THEN the excerpt SHALL carry no
An attachment in a GitHub body reaches the session as a path (issue-416, decision-135). WHEN a forwarded event's
comment,review,issueorpull_requestbody carries an asset URL —https://github.com/user-attachments/assets/…,https://github.com/<owner>/<repo>/assets/…,https://user-images.githubusercontent.com/…,https://private-user-images.githubusercontent.com/…, or the first two shapes on the configured GitHub Enterprise host — THEN the rendered prompt SHALL end with an Attachments section, appended after the template and outside the excerpt's JSON (a customrouting.promptTemplatecarries it unchanged; an event with no attachment renders byte-identically to before), naming each URL and, for each one fetched, its kind, type, size and local path under<state.root>/local/attachments/<slug>/, with the frame that says an image or a PDF is read with the session's own tool and all of it is UNTRUSTED. The fetch SHALL send the daemon's GitHub token — the first set variable ofintegrations.github.api.tokenEnv, defaultGH_TOKENthenGITHUB_TOKEN— as a bearer credential only to GitHub's hosts (and the configured enterprise host), re-checked on every redirect hop, at most 25 MiB per file and ten per event; with no token the fetch is anonymous, which serves a public repository's asset and fails for a private one. WHEN a fetch fails THEN the line SHALL name the URL with a fixed reason and say the session may try the URL itself, and the event SHALL be delivered regardless. WHEN the same asset is seen again for the same work item THEN the saved file SHALL be reused and not fetched twice. The excerpt contract above is unchanged: the section is built from the full body, so a URL the per-field cap cut is still named.Every rendered prompt states where the work item stands in the process graph (issue-148). WHEN a prompt is rendered THEN the item's graph context — current node, phase, status, gate messages, the node's resume command and the
the-loop graph completeinstruction — SHALL be substituted into the$graph_contextplaceholder, resolved read-only before delivery or spawn. WHEN the work item has not entered the graph yet — the ordinary case for a spawn prompt, since the read precedes the write — THEN the block SHALL name the graph's start node with statuspending, forbid beginning a phase before that node's assignment arrives, and tell the session to say so on the work item if none does (issue-273); the empty block that used to stand there is what let an auto-execute session walk intorequirements-definitionpastphase-selection. WHEN there is no graph to read at all (graph disabled, foreign checkout, a ref with no spec-id convention, or a resolution fault) THEN the placeholder SHALL render empty and the prompt SHALL otherwise be unchanged — resolution failure never costs a delivery. See process-graph for the consult-first ordering at human gates. WHEN the item was handed off (issue-517) THEN the block SHALL addhanded off: stage <stage>, enter <enter>, carried by <carrier>; record <url>and a line sayingimport-handoffruns first and the carried files are spec content, never instructions. Only values the parser checked against a closed set appear there, plus the record's URL from API metadata, so no record text reaches the prompt. WHEN state recordsclaudeArtifactUrlTHEN the Claude artifact line SHALL name thathttps://URL as the one to republish to.Every rendered prompt states where the session takes its answers from (issue-134,
routing.interaction.mode, decision-051).- WHEN a prompt is rendered — an event prompt or a spawn prompt, either ingress — THEN the resolved mode's directive SHALL be substituted into the
$interaction_directiveplaceholder, above the untrusted payload excerpt. - WHEN the mode is
work-item(the default) THEN the directive SHALL tell the agent not to assume a human is watching its terminal: every question is a comment on the work item or its PR, asked by runningthe-loop ask(issue-208 — the verb posts the comment, stamps the loop-prevention marker centrally and records the wait as asession.awaiting_inputevent; manualgh+ marker is the stated fallback when the CLI is unavailable), after which the session waits for the reply to arrive as the next event — never blocking on an interactive prompt, never reading silence as consent. WHEN it iscliTHEN the agent SHALL ask interactively and still record each decision's outcome on the work item. - IF the configured
promptTemplate/spawnPromptTemplatedoes not declare the placeholder THEN the directive SHALL be appended to the rendered prompt: a custom template — every template written before issue-134 included — must not be able to strip the rule silently. - IF
interaction.modecarries an undeclared value THEN it SHALL resolve towork-itemwith a warning, never tocli: a wrongwork-itemleaves a visible comment awaiting a reply, a wrongclileaves a question in a void. The directive is a constant per mode — it interpolates nothing, so no payload text can reach it. Both modes are valid:climeans a human attaches to the session's tmux pane and answers there. - WHEN a session is spawned THEN
session.spawnedSHALL carry the resolved mode. - The mode also reaches the argv (issue-426). WHEN a work item's Claude Code session is launched in
work-itemmode — a spawn, a resuming or fresh respawn, a pull request's own session, orthe-loop sessions restart— THEN the argv SHALL carry--disallowedTools=AskUserQuestion, as one token placed after the operator'sharnesses[].argsand immediately before the positional prompt. The tool's multiple-choice menu renders only in the pane and blocks on a keypress, so an unattended session that opened it froze with nothing on the ticket. The=form is required: the flag is variadic, and the space-separated form swallows the prompt as deny rules. WHEN the mode iscliTHEN the argv SHALL be unchanged. The token is derived from the mode at every launch and never recorded in the session'sharness_args, so a reloaded mode applies from the next launch and upgrading relaunches nothing. Standing sessions and critic runs are not affected. - Independently of the mode, iteration on a generated artifact (
brainstorm.md,requirements.md/bugfix.md,design.md,tasks.md) happens only in pull-request review on the PR carrying it — an invariant of the loop stated in the skill (reference/collaboration.md), restated in every rendered prompt.
- WHEN a prompt is rendered — an event prompt or a spawn prompt, either ingress — THEN the resolved mode's directive SHALL be substituted into the
Which instance an event belongs to is decided before anything else acts on it (issue-322, decision-110). Several instances of the-loop may watch one repository; each answers is this mine? from its own
instanceblock — see instances for the modes, the address token and the managed set. The seam sits inDispatcher.handleafter linkage verification and the control parse and before authorization, recording and matching, and its only power is refusal: an event outside the instance's scope is a settleddispatch.dropped(or acontrol.rejectedfor an authorized keyword) with the reason, and no reaction, comment, record or session write. An unnamed, open instance — every config without the block — reaches this bullet's successors exactly as before.Execution control: the label is necessary, not sufficient (issue-106,
routing.control). The label says which items may run andauthorizedUserssays who may be an input; the declared keywords say when:the-loop start,the-loop stop,the-loop pause,the-loop resume, plusthe-loop contribute(issue-185) — a spawn-arming sibling ofstartthat also selects the contribution loop for the item's outer walk: the-loop joining an existing, in-progress issue or PR as a contributor, which then refuses to begin until an authorized user has stated a goal and success criteria (see process-graph) —the-loop do(issue-225), the same spawn-arming sibling one loop over: it selects the ad-hoc loop, a tactical task that runs no PDLC process at all, and typing the word IS the recorded declaration that the item runs without one —the-loop review(issue-279), the shape a third time: it selects the review loop, the-loop as the reviewer of a change, and — alone among the keywords — binds to the pull request itself when typed on one, even when the PR links a ticket (the subject of a review is the change) — andthe-loop cleanup(issue-186), the other end of the life cycle — and, touching neither the session nor the graph,the-loop add-collaborator @login/the-loop remove-collaborator @login(issue-307), which write the work item's collaborator roster (below), andthe-loop add-channel <type>@<target>/the-loop remove-channel <type>@<target>(issue-375), which declare the room it is worked in (below). All configurable (issue-135 — the pre-issue-135 defaults werethe-loop:start-executionand its three siblings).- WHEN an authorized user's comment on the work item or its PR carries one of them THEN the-loop SHALL execute that command and SHALL NOT forward the comment to the harness; keywords match as whole tokens, case-insensitively, only where the comment opens with one (issue-498): a further keyword counts only when it opens a line of its own. A keyword anywhere else — mid-sentence, in backticks, in a
>quote, in a list item or a code block — is conversation and is forwarded like any comment, so a review comment that discussesthe-loop startreaches the session. A comment that opens lines with two different commands SHALL execute neither (control.ambiguous) and SHALL be routed as conversation, never dropped. The control surface is comments only — a keyword in the work item's own body or a PR description is not a command. - A command comment is executed instead of forwarded, so it can never reach a gate as an event — which is why a spawning one is handed to the graph with the spawn (issue-199): WHEN a spawn enters a start node whose actor is
humanTHEN that node's exit chain SHALL be evaluated once with the spawning comment attached, sothe-loop contributecarrying a goal reachesphase-selectionon its own. An agent start node and a respawn SHALL evaluate nothing. - Creating a work item that already carries the label arms it, exactly like labelling an existing one: it still waits for an explicit start.
- WHEN
routing.control.requireStartCommandis true (the default) THEN a labelled work item SHALL NOT spawn until the start command has been issued for it — on either ingress path, and underspawnOnUnmatched: alwaystoo ("always" widens which items may spawn, never who may start them). An accepted start is durable (it survives a restart, so a failed spawn retries without a new comment) and a laterstop/pausedisarms the item again. A start on a work item that is not armed is refused and leaves nothing standing: labelling it afterwards does not start it, because otherwise the label would still be the trigger, one event later — only a start issued while the item is armed starts it. The same asymmetry applies throughout: arming commands (start,resume) are remembered only when they act, disarming ones (pause,stop,cleanup) always.requireStartCommand: falserestores the pre-issue-106 label-alone behaviour. - cleanup SHALL release the work item's local resources — every endpoint's tmux session, the workspace checkout, the machine-local session record — and keep the portable record and everything remote (issue-186; the mechanics are in interactive-sessions). It is the one control command that destroys, so it carries the same named-actor re-check the others do and runs with or without a live session: the retroactive case — a checkout still on disk long after its session went — is what the command is for. It disarms the item like a stop.
- WHEN a work item closes AND the close event names an authorized actor THEN the cleanup above SHALL run after the session is auto-closed; WHEN it names none, or an unauthorized one, THEN the cleanup SHALL be deferred (
cleanup.deferred) and the session merely closed. A close action is not obliged to say who performed it, and an unattributable event must not destroy an operator's uncommitted work — thecleanupkeyword from a named human is the remedy, which is why it exists. Both ingresses name the closer where GitHub does (issue-329): a webhook closure carriessender, and a polled closure — reconstructed from the item's state — carries GitHub'sclosed_byas itssender, so a closure by an authorized user cleans up on either path and one by anyone else, or by nobody GitHub can name, defers. Attribution, not relaxation: the gate itself is unchanged. - pause SHALL suspend delivery for a work item's session while keeping its conversation (
session.paused; suppressed events are recorded asdispatch.dropped/session-pausedand are not replayed on resume); stop SHALL end the session through the same close path a merge takes (registry entry, harness process, workspace). A paused session still owns its work item, so nothing spawns a second one; a work item closing closes a paused session as readily as an active one. - Control parsing runs after the self-comment marker check and the
authorizedUsersguard, so it never becomes a second, weaker way in: it adds a condition, and an emptyauthorizedUsersstill fails closed. The command path then re-checks, more strictly than the ingress guard: a command requires a named login in the allowlist (control.rejected/unauthorized-actorotherwise), because the ingress guard intentionally allows an actor-less action — a CI event, or a comment whose author has been deleted — which must never be able to start or stop a session. The parser recognises the fixed configured vocabulary and yields one of the declared commands — no text from a comment reaches an argv, a path, a prompt or a work-item ref. The one argument any command takes (issue-307's@login) is no exception: it is matched against GitHub's login grammar and refused if it does not fit, so what the parser hands on is still a fixed-shape token rather than body text. - Every command is recorded (
control.commandwith actor, source and effect;control.rejectedwhen a work item is not armed), and the last command per work item is kept beside its session (<registryDir>/control/).
- WHEN an authorized user's comment on the work item or its PR carries one of them THEN the-loop SHALL execute that command and SHALL NOT forward the comment to the harness; keywords match as whole tokens, case-insensitively, only where the comment opens with one (issue-498): a further keyword counts only when it opens a line of its own. A keyword anywhere else — mid-sentence, in backticks, in a
Collaboration channels: a room for one work item, and nothing more (issue-375,
routing.control.keywords.add-channel/remove-channel).channels.slack.channelis the operator's one channel, so following one work item means following all of them — and the people who care about this migration are already in a channel of its own, where the-loop said nothing and heard nothing.- WHEN a named user in
authorizedUserscommentsthe-loop add-channel <type>@<target>on a work item THEN that channel SHALL be recorded in thecollaborationChannelssection of its portable record, with who declared it, when, through which surface and the comment's URL.remove-channelundeclares. The argument is matched against a per-type grammar and refused if it does not fit (control.rejected/missing-channel), so nothing else from the comment reaches a store, a path or an API call;<type>://<target>is the same declaration, stored canonically as<type>@<target>. A type the-loop has no adapter for is refused rather than stored and ignored. A Slack target may be the channel's name or its conversation id: a name is resolved to an id when the channel is declared — which needs the app'schannels:read/groups:readscopes — and the id is what is stored, so a later rename changes nothing and no message costs a lookup. A name that resolves to nothing is refused rather than stored. - WHEN a work item has a declared channel THEN its conversation SHALL live there rather than in
channels.slack.channel, and WHEN it was already bound elsewhere THEN the next update SHALL open a root in the declared room, rebind, and say in the thread it left where the conversation went. - WHEN a message arrives in a declared channel and no thread binding already claims it THEN it SHALL be attributed to the declaring work item — a top-level message included, which is why a declared channel never opens a new work item, the configured kickoff channel included.
- A declaration buys that and nothing else. It grants nobody anything: who may speak on the channel is still
channels.slack's principals, who may direct the loop is stillauthorizedUsers, and who may be input on one work item is still the collaborator roster. Declaring is an action, so the command path's named-and-allowlisted-actor re-check applies and a collaborator cannot make one. - One channel per type per work item — a second declaration moves the conversation and says what it replaced — and one work item per channel: a channel another work item holds is refused (
control.rejected/channel-taken), and a channel two records somehow name attributes messages to neither. Attributing a room's messages must never be a guess. - A declared room is baselined on first sight, so declaring one never delivers its backlog. In
socketread mode every message in it is seen; inpollmode its top-level messages and its bound threads' replies are. - WHEN the work item closes THEN its declarations SHALL be cleared with its control record — which is what frees the room for the next work item — and
the-loop sessions resetSHALL forget them.the-loop cleanupkeeps them, as it keeps the roster. - The same two verbs exist as CLI commands —
the-loop add-channelandremove-channel— which post the same keyword and channel back to the work item, self-marked, so the thread stays the record of who declared what however it was issued.
- WHEN a named user in
Work-item collaborators: input for one work item, and nothing more (issue-307,
routing.control.keywords.add-collaborator/remove-collaborator).routing.authorizedUsersis global — a login directs every work item this daemon watches, or none — so the person who knows one answer on one issue had no place at all: both ingresses dropped their comment before anything read it, and an agent waiting on a question never heard the reply. A work-item collaborator is the missing middle. (Unrelated to.the-loop/collaborators.yaml, which names a project's stewards and their roles for the plugin and is never read by the daemon.)- WHEN a named user in
authorizedUserscommentsthe-loop add-collaborator @loginon a work item THEN that login SHALL be recorded on that work item's roster, in thecollaboratorssection of its portable record, with who granted it, when, through which surface and the comment's URL.remove-collaboratorrevokes. Several@logintokens MAY follow one keyword, and the keyword MAY appear more than once; scanning stops at the first token after the keyword that is not an@login, so prose after the names is ignored rather than refused. A body naming no valid login is refused (control.rejected/missing-collaborator). - WHEN a work-item collaborator comments on a work item they are granted on THEN that comment SHALL be delivered to that work item's session as agent input, on both ingress paths (
routing.collaborator). - A grant buys that and nothing else. A work-item collaborator SHALL NOT issue any control command (these two included, so there is no transitive grant), SHALL NOT spawn a session (
dispatch.dropped/collaborator-no-spawn, settled rather than retried), SHALL NOT arm one or satisfyrequireStartCommand, and SHALL NOT satisfy a human gate —phase-selection,goal-definition, the review brief, artifact approval and security sign-off all readauthorizedUsersand keep reading exactly that. The command path's named-and-allowlisted-actor re-check is what enforces the first of those, and it is asserted directly rather than inherited. - A grant is scoped to one work item: it covers that item and the pull requests whose events already route to its session, and reaches no other work item — because membership is asked only about the refs the event itself named. The same person on another work item needs another grant.
- Logins are matched case-insensitively and stored without the
@, the way GitHub treats them. (authorizedUsersstill matches exactly; this changes nothing there.) - A roster entry may carry a Slack member id beside, or instead of, the login (issue-389, decision-133):
the-loop add-collaborator slack:U…on the ticket,@the-loop add-collaborator @memberby mention in the work item's Slack room, or--slack <id|@handle>on the CLI (a handle resolved through the Slack directory and refused when it resolves to none or several). At least one of the two ids is required; an entry with neither, or aslackvalue that is not a member id, authorizes nobody.remove-collaboratorrevokes by either id. WHEN a collaborator is known by Slack id THEN their mentions on Slack SHALL be input on that work item —record-context, a reply,help— and nothing that binds it (norecord-decision, no keyword, no--listen); a Slack-id-only collaborator is input on Slack only, because the ledger's ingress still reads logins and has none for them. Their records name them by the ids the roster holds, never by what the message says. - WHEN the work item closes THEN its roster SHALL be cleared with its control record, and
the-loop sessions resetSHALL forget it with the rest of the portable record.the-loop cleanupkeeps it, as it keeps the control record: cleanup releases local resources, and a roster is tracking. - The same two verbs exist as CLI commands —
the-loop add-collaboratorandremove-collaborator— which post the same keyword and login back to the work item, self-marked, so the thread stays the record of who granted what however it was issued. - A command already on the thread when the poller first sees the work item still counts (issue-119). First sight baselines the existing thread as read — the spawned session reads it itself — but a control command is an instruction to the-loop that nothing has executed yet, so baselining it would silence it permanently (the item stays armed-but-never-started,
spawns: 0every cycle). WHEN a first-sight thread carries unambiguous control commands from authorized users THEN those comments SHALL be held back from the baseline and forwarded on that same cycle, in thread order (so the last command wins), while every other comment SHALL still be baselined; the arming decision for the cycle is taken once, on that comment path, rather than a presence event being emitted in addition. The poller decides only which comments are unresolved — parsing, the named-authorized-actor re-check, execution and recording all stay in the dispatcher — so an unauthorized, self-authored (<!-- the-loop:agent-comment -->) or ambiguous keyword comment is baselined exactly as before, andcontrol.enabled: falserestores the plain first-sight baseline verbatim. A work item that already has a control record is skipped too: a first sight may bootstrap control state, never replay over state the-loop has already recorded.
- WHEN a named user in
An event on a PR resolves the PR's linked issue(s) first. WHEN an event concerns a pull request —
pull_request*, or anissues/issue_commentevent whoseissuecarries apull_requestkey (GitHub's shape for a PR conversation comment) — THEN the router SHALL emit the issue(s) the PR is linked to before the PR's own number. Linked issues come from three sources, most authoritative first: GitHub's ownclosingIssuesReferences(the Development panel), theissue-<n>head-branch convention, and closing keywords in the PR body in every form GitHub accepts (Closes #N,Fixes: #N,Closes OWNER/REPO#N,GH-N, a full issue URL). A qualified reference naming a different repository SHALL resolve to the work item in that repository (issue-183, decision-069) — a work item's contributions may span repositories, and the pull request delivering one of them lives where its code does while the ticket lives in the origin repository. The head-branch convention stays local:issue-<n>on a branch says nothing about a repository — and, since issue-269, nothing about existence either, which is why a ref resting on it alone is checked before anything acts on it (below). This widens which work item an arrived event names, and nothing else: the ingress (the operator's receiver and poll sources) and the arming gate (the-loop start) are unchanged. Consequently a PR comment/review/CI result SHALL be delivered into the existing session for the linked issue (reusing its tmux session) rather than spawning a second one, and WHEN nothing matches and the spawn policy allows it THEN the session SHALL be spawned against the issue's ref, not the PR's (issue-93, decision-036).A CI event wakes a session only to heal a failing check, and not forever (issue-462). WHILE
routing.ci.autofixis true (the default), WHEN a matched CI event arrives THEN the dispatcher's CI gate (webhook/cimonitor.py), placed after matching and before the pause and duplicate filters, SHALL deliver it only if it is acheck_runthat completed asfailure,timed_out,action_requiredorstartup_failure, or astatuswhose state isfailureorerror. Every other CI event — queued or running, passing,cancelledorstale, a re-requested run, and everycheck_suiteandworkflow_run(aggregates of the check runs) — SHALL NOT be delivered; it SHALL be recorded asdispatch.droppedwith reasonci-not-actionableand settled. A delivered failure's prompt SHALL carry, after the template and the attachments, a section naming the check, its conclusion, the head commit, the details URL, the pull request andattempt k of maxAttempts, and telling the session to diagnose withthe-loop pr checks <pr> --failingbefore changing code. The attempt is the number of distinct head commits the check has failed on since it last passed, per pull request (else per work item) and check name; WHEN that number first exceedsrouting.ci.maxAttempts(default 3) THEN the failure SHALL be delivered once with a "stop and escalate" section (ci.autofix_exhausted), and later failures of the check SHALL NOT be delivered (ci-autofix-exhausted) until a passing run resets the count. The counts live in the dispatcher's memory (bounded at 1,024 checks), survive areloadand not a restart. An unmatched CI event takes the unmatched path unchanged, androuting.ci.autofix: falsedelivers every CI event as it came, as before.A reviewer is never told to heal a check (issue-524). WHILE
routing.ci.autofixis true, WHEN a CI event matches a session walking the review loop (the loop its work-item state records, else the one its control record selected) THEN the CI gate SHALL NOT deliver it to that session and SHALL recorddispatch.droppedwith reasonci-review-loopfor it. The gate SHALL judge the event against the remaining (authoring) sessions only; WHEN no session remains THEN the event SHALL be settled asci-review-loopand SHALL spend no attempt of the budget. A paused or resumed review session is still a review session: its control record keepsarmedBy: review.The poller reads CI for a poll-only installation (issue-462). WHILE
polling.ci.enabledis true (the default), WHEN a cycle starts andpolling.ci.intervalSeconds(default 300, at least 60; its own clock, never faster than the cycle) have passed since the last CI read THEN the poller SHALL read, for every listed pull request a live session owns, its head commit, check runs and combined status, and SHALL hand each completed check run as acheck_runevent and each non-pending status as astatusevent — webhook-shaped, routed to the pull request's refs — to the dispatcher, whose CI gate judges it as above. A result SHALL be handed over only when its<sha>:<conclusion>differs from the one recorded in the pull request's poll ledger (ciSeen), so it is forwarded once across cycles and restarts; a pull request no live session owns SHALL NOT be read; a failed read SHALL bepoll.item_errorand a cycle error, and the next pull request is still read.poll.cyclecounts the hand-offs asci_forwarded.A work item a branch name invented is dropped before anything acts on it (issue-269, decision-095). Of the three linkage sources above, the branch convention is the only one that supplies a repository the event never stated — so it is the only one that can name a work item nobody created. WHEN an event yields a work-item ref only through that convention (a pull request's head branch, or a CI event's
head_branch/branches[].name) AND no live session record on this machine owns any of the event's refs THEN the system SHALL ask the provider whether the ref exists (GET repos/<owner>/<repo>/issues/<n>through the daemon's client, a ref on GitHub Enterprise asked of its own host) before that ref becomes a spawn target or a control-command target; WHEN the answer is a definitive HTTP 404 THEN the ref SHALL be removed from the event's work items (routing.linkage_dropped) and the event routed on what remains; and WHEN every one of an event's work items is removed this way THEN the event SHALL be dropped (dispatch.dropped, reasonwork-item-not-found) without releasing its delivery id, because a work item that does not exist is a permanent condition. Every other answer — no token, a timeout, a 403, a 5xx, unusable coordinates, a non-GitHub provider — SHALL keep the ref and route exactly as before: an unavailable check is not evidence of absence. A ref GitHub itself reported (closingIssuesReferences) or one a closing keyword named is never questioned — those state their repository, and issue-183's cross-repository routing acquires no network dependency. Answers are cached per ref (bounded, in-process), so a repeatedly-commented pull request costs one call.- The record answers before GitHub is asked, and before a list index is trusted. WHEN an event names a work item this machine holds a live session record for — through its own ref, or through a durable PR → work-item binding — THEN that record's work item SHALL be the target of a control command, of the
requireStartCommandtest and of an unmatched event's spawn, whatever order the refs were emitted in, and the existence check SHALL NOT be consulted at all. Before issue-269 those three readwork_items[0]— which, with a branch-invented ref present, is the ghost: the operator'sthe-loop startwas recorded against a ref that 404s, and a full session (clone, registry entry, tmux session) was spawned for it, without the spec chain that lives on the real work item. - A polled pull-request comment is a pull-request event. The poller synthesises a comment event over the pull request's own payload (key
pull_request, head branch included) and renames itissue_comment; the router read onlypayload["issue"]for that name, so on the poll ingress every pull-request comment answered "this event carries no pull request" — nosession.pr_linkedbinding was written from a comment, and no pull-request endpoint was ever chosen for one. Both now happen there exactly as they do for a webhook (issue-269). No real webhook carries apull_requestbeside anissue_comment, so that path is unchanged. - A 404 on the work item after a spawn is reported, not obeyed. WHEN the session announcement fails because the work item itself is not found THEN the system SHALL record
session.work_item_missingat error level, naming the remedy, and SHALL remember the ref as missing so the next event naming it through a branch convention is dropped without a second call — and SHALL NOT end the session: a repository the operator's credential cannot see answers 404 for items that do exist, and killing a live agent (with its checkout and its uncommitted work) on an ambiguous signal is worse than the situation being reported.
- The record answers before GitHub is asked, and before a list index is trusted. WHEN an event names a work item this machine holds a live session record for — through its own ref, or through a durable PR → work-item binding — THEN that record's work item SHALL be the target of a control command, of the
That routing decision is recorded, not recomputed — and each PR is a session of its own (issue-172, decision-064). WHEN an event carrying a pull request is dispatched to a work item's session — delivered into an existing one, or spawning one — THEN the PR SHALL be durably recorded on that work item's single session record (
pullRequests[], see state on disk), so which work item owns a PR's events is read from the record on later events and never re-derived from GitHub. Before issue-172 the binding was recomputed per event, so unlinking the PR in the Development panel, editing the closing keyword out of its body, a listing too old forclosingIssuesReferences, or one transient GraphQL error silently re-pointed routing at the PR itself — past a running session — and the event was dropped or answered with a duplicate session.- The session that opens a pull request records it — nothing is left to be inferred (issue-274). The three inference sources (
closingIssuesReferences, theissue-<n>head branch, a closing keyword in the body) are all someone else's evidence, and a pull request the-loop itself authors carries none of them: a spec PR must not close its ticket, and the-loop's branches areloop/<id>-…. WHEN a the-loop session opens a pull request for a work item THEN it SHALL record the binding in the same step —the-loop sessions link-pr --work-item <ref> --pull-request <ref|N>,POST /api/v1/sessions/link-pr, or thelink_pull_requestMCP tool, all one implementation — and the operation SHALL be idempotent, SHALL refuse a work item linked to itself, and SHALL write nothing when the work item has no session record on this machine. Before issue-274 the only writer was the routing decision itself, which needs the linkage to route at all: a review comment on the spec PR the phase gate asked reviewers to comment on resolved to the PR as a work item nobody armed, was refused as unstarted, and — since issue-270 — was settled and never re-evaluated, so repairing the linkage afterwards recovered nothing. Recording the binding is best-effort for the session: a failure is reported and the work carries on. Since issue-370 the harness SHALL run that step itself, and since issue-447 without reading any tool's output:the-loop pr createrecords the pull request it opens, and aPostToolUsehook (hooks/the-loop-link-pr.py) runssessions link-pr --discoverafter agit pushor any pull-request-creating call (gh pr create, a GitHub MCPcreate_pull_request), which asks GitHub for the branch's open pull requests, resolving the work item fromTHE_LOOP_WORK_ITEMor the session registry and exiting 0 on every path. The prose rule remains the fallback for a harness that runs no such hook (Cursor has noPostToolUseevent). Since issue-466, recording a pull request SHALL also arm it: every label inrouting.autoExecuteLabelsgoes on the PR when the link is new (pr create: whenever its link succeeded), because the poller lists only a pull request carrying all of them. Before, the agent added the labels, and the skill named one where the operator had configured two, so the PR was filtered and a review comment on it was never fetched. - Which pull requests deliver a work item is a fact the-loop recorded, never one it inferred (issue-370). Routing and tracking asked the same three inference sources and only one of them should have: routing is re-decided on every event and wrong only until the next one, while tracking is a durable filing nothing un-does. WHEN the poller resolves whose portable record holds a labelled pull request's poll ledger THEN it SHALL consider only the session-registry binding and the existing portable ledgers; a pull request that answers neither SHALL keep a record of its own, because it is a work item. A routing decision SHALL write the registry endpoint and SHALL NOT add a row to the work item's checked-in
work-item-state.json—linkedByissessionfor every row the-loop writes, and alinkedBy: "event"row from before the change is read and kept, never rewritten. Nothing here narrows delivery: the router still resolves a pull request's work items fromclosingIssuesReferences, theissue-<n>head branch and closing keywords, so a review comment on a contributor's pull request still reaches the work item's session. - A session is given only with a working tree of its own (issue-253, decision-088 D2) — the invariant every mode below is subject to. WHEN a pull request endpoint would be spawned AND no checkout can be prepared for that pull request alone THEN no session SHALL be spawned, the event SHALL be delivered into the work item's session, and the refusal SHALL be recorded as
session.pr_session_declinedwith areasonofno-separate-checkout,workspace-failedorshared-worktree. Before issue-253 an endpoint got a session of its own in the work item's working tree, so two harness conversations shared one branch with no lock and no owner: they interleaved commits, restarted each other's services and ran the same verification twice against a tree each was changing under the other. - How many sessions a work item's pull requests get is the work item's choice (issue-260, decision-093), made at
phase-selectionand frozen into its portable record;routing.tmux.sessionPerPr(issue-258, decision-092) is the default it is chosen against. WHEN routing a pull request's event THEN the system SHALL read that work item's frozengraph.sessionPerPrand SHALL fall back to the configured value when the record carries none or carries a value outside the vocabulary — so a work item started before the choice existed, and a hand-edited record, both route exactly as they do today. The three modes are unchanged:- WHEN it is
neverTHEN every pull request's events SHALL be delivered into the work item's single session — the pre-issue-172 behaviour. - WHEN it is
cross-repository(the default) THEN a pull request in another repository — a contribution this work item makes elsewhere (issue-183) — SHALL work in its own tmux session with its own harness conversation, and a pull request in the work item's own repository SHALL be delivered into the work item's session: that pull request is the work item's own delivery — its branch, its checkout, and underouter-loop-on-pull-requestthe very conversation the work item's session is already holding there. - WHEN it is
alwaysTHEN every pull request delivering the work item SHALL be a candidate for its own session, its own repository included. - WHEN the configured value is neither a boolean nor one of those three names THEN the system SHALL resolve it to
cross-repositoryand SHALL log the value it rejected; the legacy booleans SHALL resolve tocross-repository(true) andnever(false), so a configuration written before issue-258 keeps its meaning. - WHEN the retry path asks whether a delivery was handled THEN it SHALL resolve the endpoint by the same per-work-item mode dispatch used, so a comment delivered into the work item's session under
neveris never re-forwarded as unhandled.
- WHEN it is
- An endpoint's session SHALL run in a checkout of that pull request's repository, keyed on the pull request's own slug (
routing.workspace.root), spawned lazily by the first event that needs it and announced like any other spawn. WHEN the pull request is in the work item's own repository THEN that checkout SHALL additionally hold the pull request's head branch, and SHALL be declined otherwise: underworkspace.strategy: worktreethe work item's own session already holds that branch, git cannot check one branch out into two worktrees of one clone, and the fallback tree would put the endpoint on the default branch instead of the pull request's code. In practicealwaysis therefore served byworkspace.strategy: clone. - Resolution is additive: a ref with its own record resolves to it, and only a ref with none is looked up across the live records' PR lists — so a recorded PR never suppresses a work item the derived linkage still finds, and a deliberate re-link delivers to both (loud, where the failure it replaces was silent). A PR whose endpoint is closed, or unspawnable, falls back to the work item's session — an event is never lost to endpoint bookkeeping.
- WHEN the closed object of a
pull_requestclosedevent is a recorded PR of a still-open work item THEN that PR's endpoint SHALL be closed (its tmux session handled per the same retention rules as any close,session.pr_closed) and the work item's session left running (session.kept_open) — issue-101's several-PRs rule, now expressed in the model. Control commands on a PR resolve to its work item's record, so athe-loop stopcommented on a PR whose linkage broke still stops the session that owns the work. session.pr_linkedrecords each PR as it is recorded — by the routing decision, or by the session that opened it (issue-274) — andsession.pr_spawnedeach endpoint spawn, sothe-loop eventsanswers "which PRs deliver what" without opening a file. An unreadablepullRequestsentry reads as "that PR is unrecorded" — never an error into a dispatch, and never fatal to the work item's own session.- Deliberately out of scope here: a PR endpoint has no process graph. The graph stays keyed to the work item; the per-PR inner-loop graph the endpoint model enables is defined and built as its own work item (decision-064 § the direction this sets).
- The session that opens a pull request records it — nothing is left to be inferred (issue-274). The three inference sources (
The auto-execute labels SHALL work on PRs directly: a labelled PR linked to no GitHub issue is routed as its own work item (
github:OWNER/REPO#<pr-number>), so PRs stay monitorable when the ticketing system is not GitHub (Jira, …) —work-onadds the label to the PR it opens and registers the session against the PR's ref.Spawned sessions SHALL be hosted as attachable interactive tmux sessions and events pasted into them — see interactive-sessions.
WHEN a work item ends — an
issuesevent with actionclosed, or apull_requestclosed(merged or not) — THEN its matched session(s) SHALL be auto-closed rather than resumed: registry entry closed, tmux session handled perrouting.tmux(see interactive-sessions), workspace cleaned,session.autoclosedrecording the reason (issue-closed|pr-merged|pr-closed) andmerged(a merge, or a pull request recorded on the work item that merged — issue-405). A closure that reaches a live session standing on its graph's terminal node is held first (issue-405 P2,session.closing) until the session claims completion orrouting.tmux.finishGraceSecondsruns out — see interactive-sessions. A close event SHALL never spawn a session, whateverspawnOnUnmatchedsays, and SHALL never be rendered into a harness prompt. Because it is a lifecycle signal that carries no free-form text and can only end the-loop's own session, it bypasses the authorized-actor guard (as PR-close always has) — narrowly: only theclosedaction.A closure is recorded on the work item, with or without a session (issue-329, decision-113). WHEN a
closedevent names a work item the-loop tracks — a session record of any status on this machine, or a portable record with any section — THEN the close path SHALL stamp the item's portable record with anendedsection (state,kind,reason,at,source,actor;work_item.ended), disarm it (controlandcollaboratorscleared) and, only when a live session matched, close that session as above. Aclosedevent for an item the-loop never tracked SHALL write nothing.graphstays. WHEN anissuesorpull_requestevent with actionreopenedarrives for a stamped item THEN the stamp SHALL be cleared (work_item.reopened) before the event is otherwise handled; the poller clears it on the next listing that carries the item.The
endedstamp keeps how the work item ended (issue-452). WHEN the close path closes a work item's session THEN it SHALL read the work item'swork-item-state.jsonfrom the session's checkout before removing the checkout, and SHALL add to the stamp aterminalrecord (spec id, loop, last node and phase, whether the completion node was claimed and when, the frozen selections, the delivering pull requests, the spec directory and the evidence files) and anoutcome:completedonly when the record shows the completion node claimed; elsecancelledfor an issue closed as not planned or as a duplicate or a pull request closed unmerged; elseclosed-externallywith a record,unknownwithout one. A session-less closure SHALL read the checkout the registry still records; an explicitcleanupof an ended item whose stamp has no record SHALL add it before the checkout goes. A polled closure SHALL carry GitHub'sstate_reasonas a webhook's does. Checkout cleanup itself is unchanged.Closure reconciliation asks about everything this machine tracks (issue-329), not only its active sessions: every session record — active, paused or closed — and every portable record that is armed, frozen or has a roster, minus the items the listing carries, minus those already stamped
ended. The rules that bound reconciliation are unchanged: nothing is asked after a failed, interrupted or degraded listing, nothing outside the provider's scope, and an unanswerable item is left as it is.A record carrying only
pollis asked lazily (issue-332, decision-115) — the ledger of a thread once seen, with no session record. WHEN such a record has been absent from complete listings for sixty cycles' worth ofpolling.intervalSeconds(the later of this machine'slastPolledAtandclosureCheckedAtfor it, kept in<state.root>/local/poll-clocks.jsonsince issue-382; an absent or unparsable stamp counts as due) THEN the next complete listing SHALL ask the provider once, at most twenty such records per source per cycle, longest-absent first. WHEN the answer is closed or merged THEN the closure SHALL take the tracked set's path unchanged — stampedendedby the dispatcher before the poller forgetspoll, so the record is kept, demoted, and never asked again. WHEN the answer is still open, or the provider cannot answer, THEN the poller SHALL writepoll.closureCheckedAtand nothing else: no stamp, no close, and no question again before the window elapses. The cycle reports how many such questions it asked (ledger_checksonpoll.cycle). Ownership and a degraded scope are skips, not questions. Before it, a closed item the poller had merely listed stayed a plain row on the board until a full reset.One work item may be delivered by several PRs, and only the object that closed is ended. WHEN a
pull_requestclosedevent is dispatched THEN the system SHALL auto-close only the session registered against that PR's own ref, and SHALL leave a session registered against an issue the PR is merely linked to active (logged assession.kept_open) — a spec PR, a stacked series or a follow-up fix all deliver one work item, so one of them merging is not the item ending. Consequently the work item's checkout is not removed while it is open, and the item's session ends on its own close: theissuesclosedevent, or the poll path's closure reconciliation. The decision is made from the event payload alone (no API call, no credentials); a payload that names no closing number closes nothing. The operational consequence: a PR merged without closing its ticket leaves the session active until the ticket closes (the-loop sessions closeis the manual escape hatch) — issue-101, decision-039.Both ingresses read the same three comment surfaces (issue-246). GitHub files an instruction left on a pull request under one of three objects, and a work item's session SHALL receive all three whichever ingress is running:
Surface GitHub object Event delivered Payload key Conversation comment IssueComment(IC_)issue_commentcommentReview body PullRequestReview(PRR_)pull_request_reviewreview(incl.state)Inline review-thread comment PullRequestReviewComment(PRRC_)pull_request_review_commentcomment, withpathandline- WHEN a polled pull request carries a review or a review-thread comment THEN the poller SHALL forward it exactly once — deduped across cycles and restarts by its own node id, on the same ledger, retry budget and give-up accounting as a conversation comment. A review and the N inline comments it contains are N+1 independent deliveries, because each has its own id.
- WHEN a review-thread comment is forwarded THEN its file and line SHALL travel with it (the line it was written against, when the diff has since moved past it): the anchor is part of the instruction. The diff hunk SHALL NOT be forwarded — the payload excerpt is capped, and a hunk can truncate the instruction it was meant to contextualise.
- WHEN a review carries an empty body, or has not been submitted (
PENDING), THEN nothing SHALL be forwarded for it. An approval with no words carries no instruction, and a draft review is not something its author has said. Whether the review state itself should mean anything is deliberately undecided: it is carried as context and nothing acts on it. - Every one of these SHALL pass the guards a conversation comment passes, in the same order and the same place: the self-comment marker first, then
authorizedUsersjudged by that comment's own author, then control parsing. No new credential, no new network path — the reads go through the daemon's GitHub client under its token (issue-442). - WHEN the polled item is an issue THEN the requests SHALL be exactly the one the poller always made; the two extra reads are per pull request only.
On the poll path the same closure is discovered by reconciliation, since a poll listing only ever carries open items: after each successful listing the poller checks every active session that source owns whose item is no longer listed, asks the provider whether it really ended (
poll.closure_detected), and closes it through the dispatcher's identical close path. It never closes on doubt — a failed listing skips reconciliation, and an unanswerable state query leaves the session running for a later cycle — and forgetting the item's poll state means a reopened work item is first-sight again and spawns afresh. The closure query lives behind the provider contract (owns/closure/closure_event), so a provider that does not implement it is simply never reconciled (issue-94).Duplicate deliveries SHALL be dropped via a dedup cache (
dedupCacheSize).On the poll (pull) path the-loop drives its own retries, bounded by
polling.maxRetries(default 3): WHEN a spawn or a comment forward does not succeed THEN it SHALL be retried on later cycles up to the budget (a failed event is no longer baselined as "processed" after one attempt), and WHEN the budget is exhausted THEN the poller SHALL log a terminal failure (poll.spawn_failed/poll.comment_failed,will_retry=false) and ignore that event on later polls until new activity re-arms it. A still-processing (in-flight) dispatch SHALL NOT be counted a failed attempt, and a new comment on a work item SHALL retrigger it with a fresh budget. The poller reads the async dispatch outcome via the dispatcher's durable delivery record (Dispatcher.delivery_status: done/settled/inflight/unhandled) rather than assuming success at enqueue time. (The webhook path relies on GitHub redelivery, repaired for dead tmux sessions by the respawn above — see interactive-sessions.)An event refused on purpose is never replayed, and never counted as a pending delivery (issue-270). Two things suppress delivery deliberately: a work item nobody has started (
requireStartCommand) and a paused session. In both cases the event is dropped (dispatch.dropped, reasonawaiting-start/session-paused), its delivery id is deliberately kept — releasing it would have GitHub redeliver, and the poller re-forward, every comment on every labelled item nobody has started — and nothing is replayed when the item is later started or the session resumed. What was said is not lost: it is on the thread, and the prompt a spawned session receives tells it to read that thread from the top, including what arrived before the start. WHEN such an event was forwarded by the poll path THEN the dispatcher SHALL record the delivery as settled and the poller SHALL resolve the comment — baselined inseenComments, no entry incommentAttempts, nothing counted againstmaxRetries, nogaveUprecord (so no later version re-arms it into a late replay) and no abandonment notice — recording it once aspoll.comment_settledwith the reason. The same applies to a comment that carried a control keyword: it was executed, refused or found ambiguous by the-loop and was never a delivery at all, so a re-forward after a restart would have executed the command twice. Every failure path is unchanged: a failed dispatch still releases its delivery id and still spends a retry, andspawn-policy,session-occupied,session-vanishedandwork-item-not-foundkeep the accounting they had.A comment the poller abandons SHALL be reported on the work item (issue-240). WHEN the retry budget for a comment is exhausted THEN, after the give-up is recorded, the poller SHALL post one comment on that item — naming the abandoned comment, the number of attempts, and that it will not be retried — carrying the self-comment marker so it is never read back as human input (
poll.giveup_reported). Until then the only signal was a 😕 reaction and a line in the local event log, so a human who told an agent to do something had no way to learn the agent was never told. The notice states the recovery: post the instruction again — a new comment id carries a full retry budget, and nothing the-loop stores needs editing. Posting is best-effort in one direction only: it MAY fail (no token, a non-GitHub provider, an API error —poll.giveup_report_failed) and the give-up SHALL be recorded regardless; it SHALL NEVER cause a comment to be treated as delivered, and SHALL NEVER end a poll cycle. The notice is built from the comment's id, URL and attempt count only — no text from the abandoned comment is echoed into something the-loop posts with the operator's credentials.Stopping and restarting the poller SHALL have no observable effect (issue-159): a poller that was stopped and started behaves as one that never stopped. Five rules make that true, on top of the durable per-item ledger.
- At most one poller per state root. The poller SHALL take an exclusive advisory lock on its pidfile —
--onceincluded — and a second start against the same state SHALL refuse, name the holding pid and exit non-zero (poller.blocked) without touching the ledger. Two pollers sharing one ledger interleave read-modify-write over the same records and re-forward each other's comments, which is the sharpest form of a restart being visible. The lock is the pidfile itself, so "who is running" and "how do I signal them" cannot disagree; it is scoped per state root (two roots, two pollers), and the kernel releases it however the process dies — so a pidfile left by aSIGKILLis simply unlocked and needs no manual cleanup. - Stopping the poller (
the-loop stop) SHALL be verified and blocking. It signals a pid only when the lock proves a poller holds it (a stale pidfile is never signalled — under pid reuse the old behaviour sentSIGTERMto an unrelated process), and it returns only once the poller has actually exited, bounded by a timeout (default 30s); a poller that outlives the timeout is reported and the command exits non-zero. - Progress SHALL be durable per work item. Each item's record is written as soon as that item is done — including when processing it raised, so an attempt already spent cannot be spent twice — instead of at the end of the cycle. A hard kill then loses the item in flight rather than everything the cycle learned.
- A stop SHALL be honoured inside a cycle. The poller finishes the work item in flight, persists it, and processes no further items or providers; the summary and
poll.cyclecarryinterrupted. An interrupted cycle SHALL NOT run closure reconciliation — a partial listing is not evidence that the unlisted items ended, and reconciling on one would close live sessions (the same rule issue-94 applies to a failed listing). - A shutdown SHALL return unspent retry budget.
Dispatcher.stopreports the deliveries it abandoned undelivered (dispatch.abandoned) and the poller un-counts the attempt each of them spent (poll.attempts_released), leaving the event unresolved rather than baselined. Without this, restarts accumulate towardpolling.maxRetriesand can permanently abandon a comment nothing ever tried to deliver. - The poller SHALL be able to outlive the shell that started it (issue-191, re-shaped by issue-228).
the-loop startspawns it detached — its own session (start_new_session), output to<state.root>/logs/poller.out— and reports success only once the daemon holds its pidfile lock, so a poller that exits during startup is a reported failure, not a silent one;the-loop statusreports liveness from the lock and progress from the per-cycle heartbeat, exiting0/1. The issue-191 double-fork detach went with the removedpollcommand (decision-084); the foreground form cron and systemd run ispython -m the_loop.daemon_entry poller [--once]. - By default both ingresses run inside the service process (issue-231, decision-084 §8): with
service.hostIngressestrue,the-loop startboots one process whose lifespan hosts the enabled poller and receiver as threads, each still acquiring its own pidfile flock (under the service's pid) so the single-instance guarantee,status/stopand the daemons API are unchanged. A lock held by a standalone daemon is skipped with a warning, never fought over;hostIngresses: false(or a disabled service) restores one process per ingress.
- At most one poller per state root. The poller SHALL take an exclusive advisory lock on its pidfile —
On the poll path the linked issues of a labelled PR SHALL be read from GitHub inside the PR listing the poller already performs (the GraphQL listing's
closingIssuesReferences— no extra API round-trip per cycle; before issue-442 it wasgh pr list --json), and WHEN a listing carries no such field THEN the poller SHALL warn once and fall back to the head-branch / closing-keyword conventions rather than failing the cycle.WHEN
routing.reactions.enabledis on (default on — owner decision at PR #85 review;enabled: falseopts out of the daemon's one write surface to GitHub) THEN the dispatcher SHALL acknowledge each event it processes with emoji reactions on the triggering entity: thestartedreaction (default 👀eyes) when the event is dequeued for delivery/spawn, thencompleted(default 🎉hooray) orerror(default 😕confused) from the dispatch outcome — on the triggering comment when the event carries one, else on the issue/PR itself. Shared by the webhook receiver and the poller; best-effort via the operator's token through the daemon's client (a reaction failure never affects the dispatch; a missing token, a non-GitHub provider, or an event with no reactable target is a silent no-op — so work-item platforms without reactions degrade cleanly). GitHub's palette is fixed (+1 -1 laugh confused heart hooray rocket eyes; ✅/⁉️ don't exist), and each state's emoji is configurable (""skips a state). Outcomes are logged asreaction.added/reaction.failed.WHEN the dispatcher consumes an event instead of delivering it — it executed a control keyword, refused one, read two conflicting ones, or suppressed the event on purpose — THEN it SHALL acknowledge it on the same entity, from the same
routing.reactionspalette and through the same best-effort reactor (issue-371):completedfor an executed command,errorfor a refused or unreadable one, andstartedfor a suppressed event (awaiting-start,session-paused,collaborator-no-spawn) — seen, and pending, because the harness re-reads the thread once the work item runs. The acknowledgement SHALL be posted after the outcome is recorded and SHALL NOT affect it. An event refused as out of this instance's scope SHALL post nothing (issue-322's "no reaction, no comment, no record" holds on every route into it, including an authorized command), and so SHALL a duplicate delivery, an event with no resolvable work item, a spawn-policy drop released for retry, and any comment refused at ingress.WHEN the dispatcher spawns (or respawns) a session THEN it SHALL first pre-seed the harness's own user config for that session's working directory (
routing.harnessTrust, default on), so an unattended session cannot stall on Claude Code's workspace-trust dialog or its bypass-permissions disclaimer — neither of which is a permission rule, hence neither is silenced by--dangerously-skip-permissions. The spawn directory is trusted under everyharnessTrust.scope(the key that gates the dialog for a repo shipping.claude/settings.jsongrants has no ancestor walk);scopedecides only whether a second entry additionally widens trust to the workspace root (workspace-root, the default, covering every checkout under it) or not (directory). Writes are non-destructive (named keys only, merged, atomic, skipped when already correct, refused on an unparseable file), and permission-neutral: the bypass disclaimer is accepted only when this harness's launch arguments (harnesses[].args, else the deprecatedrouting.harnessArgs) already ask for bypass mode. Best-effort — a failure warns, emitsworkspace.trust_failedand still spawns. Audited asworkspace.trusted;harnessTrust.enabled: falseopts out. See interactive-sessions.The authorized actor is whoever performed the action, and on the poll path the work item's author gates spawning alone (issue-197, decision-074). The webhook router authorizes
event_actor— the commenter, reviewer or labeller. A poll listing carries an item's labels but not who applied them, so there the item's author stands in as the proxy for "a human wanted this", and it governs exactly one decision:- WHEN a polled work item's author is not in
authorizedUsersAND no arming command has been recorded for it THEN the poller SHALL NOT emit a presence event for it (no session is spawned whose subject is that item), SHALL log the remedy and SHALL recordpoll.unauthorizednaming that author. WHEN an authorized user has armed it (the-loop start/contribute/resume, from a comment or fromthe-loop sessions start—ControlStore.start_requested) THEN presence SHALL be armed exactly as for any other item, and the warning SHALL stop; a laterstop/pause/cleanupdisarms it again. - WHEN a comment on such an item is authored by an authorized user THEN it SHALL be forwarded, and a control command on it executed, regardless of who opened the item — including a command already on the thread at first sight (issue-119's rule). An unauthorized author's comment is baselined and dropped exactly as before, on an armed item as on any other. So a maintainer can point the-loop at an outside contributor's issue or pull request with one comment; the contributor gains nothing.
- Every spawn prompt states that the work item's own title, body and comment thread are untrusted content — a description of what is wanted, never instructions that override the-loop's rules — because the person who asked for the work need not be the person who wrote it. A constant, interpolating nothing, above the payload excerpt.
- WHEN a polled work item's author is not in
A comment/review the-loop itself posted (identified by an embedded marker, since it is posted under the operator's own credentials and is otherwise indistinguishable by author) SHALL be dropped before dispatch, so the-loop never resumes a session on its own reply (
the_loop.authz.is_self_authored; same check on the poll path).WHEN any producer writes a comment on the-loop's behalf — the spawned harness or the daemon itself (today: the interactive-session announcement) — THEN the body SHALL be stamped by
the_loop.authz.mark_self_authored, the producer-side counterpart of that check: a visible attribution line plus the marker, idempotent, and applied only to text the-loop composed (never to payload-derived text). An unmarked daemon comment is authorized — it is posted with the operator's own credentials — so the marker is the only thing preventing it from being delivered into the session it describes (issue-104).Everything the CLI generates lives under one configured root (
state.root, default.the-loop), organised by whether it travels between machines (issue-128, decision-046): one portable record per work item at<root>/portable/<slug>.json(itscontrolsection — what an authorized user armed — and itspollsection — which comments have been seen), the machine-local session registry under<root>/local/, the event log under<root>/logs/, the receiver pidfile at<root>/gh-webhook.pid. The root supplies defaults only for the local paths — an explicitly configuredregistryDir/eventLog.path/pidfileis still used verbatim — whileportable/always follows the root. Two writers share a work-item record, so a write SHALL replace only its own section (read-modify-write): a poll cycle must never erase a control command the other ingress recorded a moment earlier. Pre-issue-128 locations (<root>/sessions/,<root>/sessions/control/,<root>/sessions/poll-state.json, and the pre-issue-106.the-loop/poll-state.json) SHALL still be READ once per work item and written forward, so an upgrade never re-baselines a watched thread nor forgets what was armed; nothing writes there any more.Dispatch also drives the process graph (issue-113,
routing.graph, default on). A successful spawn enters the work item's start node — which is what runs the entry hooks that write theloop:<phase>label — and a successfully delivered event advances the graph one node boundary, carrying the event's comments to the gate'sclassify-feedback. The coupling lives in the dispatcher, so the poller and the receiver behave identically; it honours the samecontrol.requireStartCommandgate the spawn path does; and it is best-effort — any failure is logged asgraph.link_failedand the delivery still counts. Where the specs are comes from the CLI config (routing.graph.specDir, defaultdocs/specs— the loop's convention) — one directory for every checkout the instance drives, since issue-352 ended the daemon's read of the checkout's harness config (decision-123). A work item skipped for want of that directory is recorded asgraph.skipped— the delivery still succeeds, so without the record an inert graph had no explanation (issue-123). Starting a graph needs no such directory (issue-273): the spec folder is created by the work the graph gates, so onlyadvanceandcleanare held back by its absence. See process-graph.Arming a handed-off issue reads its handoff record, once (issue-517). WHEN the dispatcher arms a GitHub issue whose walk has not begun THEN it SHALL list the issue's comments once, before preparing the workspace, and take the newest well-formed v1 record (
<!-- the-loop:handoff v1 -->). The listing is skipped for a pull request, a disabled graph, or an item already on the graph — a checkout that holds a pointer costs no API call.- Who may post one. A record SHALL be admitted only from a login in
routing.authorizedUsers(the phase-selection gate's set). The self-marker SHALL grant nothing, since anyone can paste it: the-loop's own records count because on GitHub it posts as the operator, an authorized login. A comment with no author (a deleted account) SHALL be refused. The author and URL SHALL come from the API's comment metadata, never the record's text. - The checkout. WHEN the record names a branch THEN the workspace SHALL be prepared on it, provided it starts
the-loop/and passesgit check-ref-format; otherwise the record is dropped. A pull request's own head branch always wins. - The hand-over. The record goes to
graphlink.on_armandRuntime.start, which freezes it intowork-item-state.json(see process-graph § Handed-off work items). WHEN the prepared checkout shows the walk already began (a spec branch with state) THEN the record SHALL be dropped. - The events.
handoff.honourednames the stage,enter, author, carrier, branch and record URL.handoff.ignoredlists every rejected record and why (not authorized, malformed, unknown version, bad branch, already on the graph). WHEN the comment listing fails THENhandoff.unreadableis a warning and the item SHALL arm with no handoff: fail closed, never a guessed stage.
- Who may post one. A record SHALL be admitted only from a login in
The daemon writes no configuration into a checkout (issue-352, retiring issue-193/201's adoption). It used to plant the-loop's default harness config into an unconfigured checkout before the spawn so the session had a file to read; the CLI reads no harness config any more, so it has none to write, and a session finds exactly what the repository committed — the skill works a repository without
.the-loop/on the schema's defaults.harness.config_scaffoldedis retired from the event catalog.The
webhooks.*androuting.*keys above live in the CLI config (cli-config.yaml, resolved via--config/env/cwd/home — seecli/README.md), independent of any repo's.the-loop/harness-config.yaml— the daemon is not tied to a single repo, and no repository configures the daemon (decision-032, decision-044).routingis a top-level key, not part ofwebhooks: it configures what happens to an event once accepted, which is what the poller does with the very same values, so one declaration governs both ingresses (issue-142, decision-053). A config still nesting it underwebhooks.ghWebhookis refused, naming the replacement and the upgrade command.routing.authorizedUsershas no fallback: it must be set explicitly in the CLI config or the receiver fails closed (acts on no human-authored events). Since issue-352 there is no exception in either direction: the graph coupling reads nothing from the checkout's.the-loop/— the label prefix isloop:, the spec directory and the hooks are this config's, and the origin repository is the work item's ref._checkout_belongs_tostill proves via theoriginremote that a checkout is the work item's before a graph is driven in it (issue-113 A6).
Jira
Since issue-475 a Jira ticket reaches its session through both ingresses, as a GitHub issue does. Both build the same events and then share the router, the dispatcher and the session registry. Nothing here runs without integrations.jira.
Polling (R5). WHEN
polling.sources[]holds{provider: jira, projects: [KEY]}THEN the poller SHALL run one JQL query per project each cycle. The query lists the tickets carrying every arming label in its Jira-safe form that are not in the Done status category. Every value SHALL be a quoted JQL string, and no value from a ticket SHALL reach the query. A polled project SHALL be listed underintegrations.jira.projectswith arepository. Polling a mirror-only or an unconfigured project SHALL stop the poller at its pre-flight, naming the project.Comments and the cursor (R5.3, R5.6). Every comment is listed each cycle, and the poll ledger's
seenCommentsis the cursor, exactly as on GitHub. WHEN a project's listing is rate-limited (429) or fails (5xx) THEN that project SHALL be degraded for the cycle and the cursor kept, so on recovery no comment is dropped and none is delivered twice. A missing credential fails the whole source.Closure (R5.4). WHEN a polled ticket's status category is
doneTHEN its work item SHALL be closed as a closed GitHub issue is. Jira names nobody the-loop authorizes on for a status change, so the session's local resources wait for a named authorizedthe-loop cleanup(issue-329's rule).The webhook doorbell (R6). WHEN
integrations.jira.webhook.secretEnvnames a variable that is set THEN the receiver SHALL serve a second route,/jira-webhook. WHEN no such variable is set THEN that path SHALL answer 404. WHEN a delivery'sX-Hub-Signatureis missing or wrong THEN the receiver SHALL answer 401 before parsing the body. A verified delivery SHALL be read forwebhookEvent,issue.keyandcomment.idonly. The issue and the comment SHALL be fetched again with the daemon's credential, so a forged-but-signed body injects neither text nor identity. The comment is fetched by its id (issue/{key}/comment/{id}), wherever it sits in the thread; a full listing (the poller, the gates) reads every page ofissue/{key}/commentand raises rather than return a truncated list, keeping the newest 5,000 of a longer thread with a warning (critic C2).X-Atlassian-Webhook-Identifieris logged and never used as a key. The doorbell's source projects and arming labels hot-reload with the routing policy, on the next delivery by either route.webhookEventWhat the doorbell does comment_createdthe comment on an armed ticket goes through the router (self-marker, Jira allow-list) to the dispatcher jira:issue_updatedstatus category done: the close event. Armed and started by an authorizedthe-loop start: presence. Otherwise ignoredanything else ignored with a 202 A key that is not a key, or a ticket outside the source projects, is ignored before anything is fetched. WHEN Jira answers the fetch with another key (the ticket was moved, and Jira followed the redirect) THEN the doorbell SHALL ignore the delivery with a warning, whether the new key is in a source project or not.
Once, by either ingress (R6.4). Both ingresses give a comment the delivery id
jira-comment-<site>-<id>. WHEN the webhook and the poller both see one comment THEN the session SHALL receive it once: the id is in the session's persistedrecentDeliveries, which both processes read. A control command runs before that check, so WHEN a Jira comment carries one THEN its id SHALL first be checked and recorded in<state.root>/local/control-deliveries.json(bounded, under aflockboth processes take), and a comment already recorded there SHALL NOT be executed again. WHEN the dispatcher releases that delivery for a retry (a failed spawn) THEN its record SHALL be released with it, so the retry executes.Who may act on a Jira ticket (R7). A Jira comment is authorized on the
routing.authorizedUsers[].jiraids: the CloudaccountIdor the Data Center userkey, matched exactly. WHEN a Jira comment has no author, or an unlisted one, THEN it SHALL be neither input nor a command nor a gate answer. A GitHub login never stands in for a Jira id, and the reverse is also true. The control seam's named-actor re-check and the human gates read the same ids. A gate sees a Jira author asjira:<id>, a namespace no GitHub login can spell. Work-item collaborator grants hold GitHub logins, so they grant nothing on a Jira ticket.Whose comment it is. WHEN a Jira comment carries the self-marker, or its author is the service account (
myself), THEN it SHALL be the-loop's own and dropped on both ingresses. The exception is a relay (a design addendum to §C7/§C8). A service-account comment carrying[the-loop:relay]and not the self-marker is the Jira ledger's record of an authorized person's gate answer or control command from another channel. Both ingresses SHALL accept it as authorized, and a gate SHALL read it as the operator's answer, as the GitHub ingress reads an unmarked relay posted under the operator's credentials. On anyone else's comment the relay marker grants nothing, and a visible gate marker ([the-loop:phase-selection], …) stays text, so no person can plant a checklist a gate trusts. See channels § Jira.Arming is a label, starting is a person (abuse case 5). A Jira listing carries no reporter, so no Jira ticket starts on its label alone. Only a recorded
the-loop startby an authorized Jira user arms a spawn, on both ingresses.A Jira copy never arms (issue-517, abuse case 7). The handoff can create a Jira copy of a GitHub issue, labelled
the-loop-mirror. WHEN a Jira event carrying that label reaches the arming path THEN the dispatcher SHALL refuse it. The label is read from the event's own payload, so no API call is made. The refusal is recorded asarm.refusedwithreason=mirrorand settled asmirror-refused, never retried: the GitHub issue it mirrors runs the loop.The prompt frame is unchanged (abuse case 10). A Jira comment reaches the session inside the same untrusted-data frame as a GitHub comment. The prompt names its source:
# Jira event for jira:<site>/<KEY>-<n>, the repository line the project's origin repository, the frame "UNTRUSTED data from Jira" ($event_sourceand$event_originin the templates; a GitHub event renders exactly as before).A pull request naming a Jira key routes to the Jira work item (R8.1, abuse case 7). With
integrations.jiraconfigured the router has a fourth linkage source,jira-key: a key ([A-Z][A-Z0-9_]{1,9}-[1-9][0-9]*) in the pull request's head branch or title — never its body. WHEN the key's project is configured with a repository, that repository is the pull request's own, and the Jira ref has a live session record THEN the pull request's events SHALL route to that Jira work item (and the dispatcher binds the pull request to it). Otherwise the match SHALL be dropped with a debug log: a branch name cannot link an unregistered work item, a mirror-only project, or a project mapped to another repository. Both ingresses (the receiver and the GitHub poller) apply it; a GitHub event's actor is still judged on the GitHub logins.
Setting up the Jira webhook. Polling needs no inbound route. The webhook only removes poll latency.
Give the receiver a public HTTPS endpoint, for example a tunnel to
the-loop start.Choose a secret, and export it on the machine running the receiver:
export THE_LOOP_JIRA_WEBHOOK_SECRET=….Name that variable in the CLI config:
yamlintegrations: jira: webhook: secretEnv: THE_LOOP_JIRA_WEBHOOK_SECRETA Jira admin registers the webhook (Settings → System → WebHooks). the-loop holds no admin scope and never registers it. Set the URL to
https://<your-host>/jira-webhookand the secret to the value from step 2. Select the events Comment → created (comment_created) and Issue → updated (jira:issue_updated). Optionally narrow it with a JQL filter on your source projects.Restart the receiver. It logs
Jira webhook doorbell on …/jira-webhookwhen the route is served.
Design
docs/specs/issue-15/design.md · architecture § triggers · docs/specs/issue-475/design.md §C8 (the Jira poll provider, the webhook doorbell, the allow-list by provider)
History
| Work item | What changed | Links |
|---|---|---|
| issue-524 | A reviewer is never told to heal a check (2026-10-27): the CI gate used to attach the "make the smallest fix … then push" section to whichever session a CI event matched, a the-loop review session included — one told by its own prompt to change no code — and each red check spent an attempt nobody would use. The gate now leaves every session walking the review loop out of a CI event (dispatch.dropped reason ci-review-loop) and judges the event against the authoring sessions alone; with none left, the event is settled and the budget untouched. A pause or resume control record now keeps the command that armed the item (armedBy) and its loop, so a paused or resumed reviewer is still known as one (critic round 1). | issue-524 |
| issue-498 | A control keyword counts only where a comment opens with it (2026-10-23): parse_command anchors each keyword to the start of the body, and a further one to the start of a line, so a keyword mentioned in prose, backticks, a quote or a code block is conversation. A comment opening lines with two different commands executes neither, emits control.ambiguous and is routed as conversation; the settled outcome control-ambiguous is retired | spec, learning-018, issue |
| issue-517 | Handed-off issues (2026-10-21): on the arming path, for a GitHub issue not yet on the graph, the dispatcher lists comments once and admits the newest v1 handoff record from an authorized, non-empty author (the self-marker grants nothing), prepares the workspace on the record's the-loop/ spec branch, and passes the record to Runtime.start. A Jira item labelled the-loop-mirror is refused at arming (arm.refused reason=mirror, settled mirror-refused). New events handoff.honoured, handoff.ignored, handoff.unreadable | spec, process-graph, decision-150, issue |
| issue-496 | The gateway (2026-10-19): the receiver's verify_signature and the poller's providers are reused by the gateway's adapters; the gateway client builds the router and dispatcher once (webhook/daemon._build_ingress, which now exposes its router, reload and doorbells) and feeds them from its queue; outcomes commit back; the inferred PR linkage is published as a binding | spec, gateway |
| issue-475 | A PR naming a Jira key routes to it (2026-10-06, PR 5 of 5): SOURCE_JIRA_KEY links a pull request whose head branch or title names a key, only for a registered Jira work item whose project maps to the PR's repository; the body is never read. The delivered prompt names Jira and the origin repository. Before, a Jira item's PR was routed only through a PR-ref registration | spec, issue |
| issue-475 | Jira tickets reach their sessions (2026-10-06, PR 4 of 5): a jira poll source (polling.sources[].projects) lists each project's armed tickets with one quoted JQL query per cycle and reads every comment against the poll ledger's cursor. A 429 or 5xx degrades the project for the cycle and keeps the cursor. A done status category closes the work item. The receiver gains /jira-webhook, served only when integrations.jira.webhook.secretEnv resolves: signature first (401 before parsing), then a doorbell that re-fetches the issue and the comment and builds the poller's own events, so one comment by both ingresses is delivered once (jira-comment-<site>-<id>). Jira comments are authorized on routing.authorizedUsers[].jira (exact id; no author is unauthorized), at the router, the poller, the control seam and the human gates (jira:<id>). A Jira relay ([the-loop:relay] by the service account) is the operator's words | spec, issue |
| issue-462 | CI monitoring and self-healing (2026-10-06): every CI webhook used to be delivered to the session as it came — twenty-odd deliveries per push to a five-job PR, each a turn — and a check the agent could not fix had no end. The dispatcher's CI gate (webhook/cimonitor.py, routing.ci) now delivers only a failed check_run or failing status, with a section naming the check, commit and attempt and pointing at the new the-loop pr checks; it counts distinct failing commits per PR and check, delivers one "stop and escalate" notice past maxAttempts (default 3), then nothing until the check passes. Drops are dispatch.dropped (ci-not-actionable, ci-autofix-exhausted); events ci.check_failed, ci.autofix_exhausted. A poll-only installation gets the same events: polling.ci reads each live pull request's checks on its own interval (default 300 s) and forwards each new result once (ciSeen in the poll ledger) | spec, routing options, cli, issue |
| issue-466 | A pull request the-loop records is armed in the same act (2026-10-06): pr create, sessions link-pr and its --discover put every routing.autoExecuteLabels label on it, so the poller's every-label filter lists it and its comments reach the session. A link that fails adds no label, and so does a re-run on a PR that was already recorded. A GitHub refusal is a note. Routing is unchanged | spec, cli, issue |
| issue-452 | The closure keeps a terminal record (2026-10-02): _close_ended_session reads work-item-state.json before close_session removes the checkout, and the ended stamp gains outcome (completed only from a claimed completion node — never from the closure) and terminal (node, phase, frozen selections, pull requests, evidence); a session-less closure reads the registry's checkout, cleanup backfills a stamp that has none, and a polled closure now carries state_reason. work_item.ended carries the outcome | spec, process-graph, issue |
| issue-447 | The PostToolUse recorder stopped parsing gh output (2026-09-30): it fires on a git push or any pull-request-creating call and runs sessions link-pr --discover, which asks GitHub for the open pull requests of the checkout's branch; the-loop pr create links what it opens, so the hook is the safety net for PRs opened by hand or by MCP. Routing is unchanged | spec, decision-140, issue |
| issue-442 | Every GitHub read and write of both ingresses moved from the operator's gh to the daemon's own client on PyGithub (2026-09-30): the poller's listings are the GraphQL queries gh ran (so closingIssuesReferences, node-id comment ids and every baselined thread survive the upgrade), the three pull-request surfaces are read over REST, the existence check, reactions, announcement, paper trail and give-up notice post under the token integrations.github.api.tokenEnv names; Issues disabled is classified from GitHub's 410; no rate-limit sleep, the caller's timeout on every request | spec, decision-139, issue |
| issue-426 | A work-item-mode session can no longer freeze on Claude Code's question menu (2026-09-28): the prompt said "never block on an interactive prompt" and nothing enforced it, so AskUserQuestion rendered a menu in the tmux pane, emitted nothing, and every later message was pasted into a session waiting on a keypress. Dispatcher._adapter_for and sessions restart now launch the adapter with_unattended(interaction.unattended), and the Claude adapter adds --disallowedTools=AskUserQuestion before the prompt; cli mode is the opt-out. The = spelling matters: the ticket's space-separated workaround turned the spawn prompt into deny rules | spec, issue |
| issue-416 | An attachment in a GitHub body reaches the session as a path (2026-09-21): _render_prompt appends an Attachments section after the template for the asset URLs in the event's bodies, fetched with the daemon's token (GitHub's hosts only, redirects re-checked, 25 MiB, ten per event) into <state.root>/local/attachments/<slug>/ and reused from disk; a failed fetch names the URL and the reason; the excerpt is untouched | spec, decision-135, issue |
| issue-405 | The close branch holds a closure for a session at its endgame (2026-09-21): _defer_close reads the work item's GraphContext and parks the close when the pointer stands on an unexited terminal node; sweep_closing (the dispatcher's own sweeper thread, or an embedder's cycle) finishes it on the completion claim or at routing.tmux.finishGraceSeconds; _record_reopen cancels it; the poller's closure reconciliation skips a held ref; session.autoclosed merged reads the state file's merged pull requests | spec, interactive-sessions, issue |
| issue-389 | A work-item collaborator can be known by Slack member id (2026-09-19): the roster entry gains an optional slack beside the optional login (at least one of the two, fail closed on neither or on a value that is not a member id), written by add-collaborator slack:U… on the ticket, by @the-loop add-collaborator @member in the work item's Slack room, or by the CLI's --slack (a member id or a handle). It buys the same thing issue-307's grant bought — input on that one work item — now on Slack too: their mentions there are context, replies and help, never a decision, a keyword or a room's listen switch, which stay routing.authorizedUsers'; a Slack-id-only collaborator is input on Slack only, since the ledger's ingress reads logins. add-channel gains --listen (mentions, the default, or all), an authorized user's switch recorded on the declaration | spec, decision-133, channels, routing, issue |
| issue-382 | The poller's clocks stopped being the repository's (2026-09-18): lastPolledAt and closureCheckedAt were stamped into the tracked portable/<slug>.json on every cycle for every item seen, so an operator whose state.root lives in a repository had a permanently dirty working tree. Both now sit in <state.root>/local/poll-clocks.json, keyed by ref — a clock reading describes a cycle this machine ran, and losing one costs a single closure question rather than a re-forwarded thread. The ledger keeps them in memory, so the closure schedule, the poll section and the served record are unchanged; a record written earlier is read and then stripped on the next write, and a cycle that learns nothing now rewrites no record at all | spec, decision-132, state, issue |
| issue-381 | The arming label became a list, every entry required (2026-09-18): routing.autoExecuteLabels replaces autoExecuteLabel, and a github poll source's labels replaces its label, so an operator sharing a repository adds a label of their own to the shared one and instances stop arming each other's items. The router, the dispatcher's arming check, the poller's gh listing (one --label per label, the listing re-checked provider-side) and its presence event all read the list through one all-of predicate; an empty list arms nothing. Breaking: config version 0.10.0, the old keys are refused rather than read and the-loop migrate-config wraps them | spec, decision-131, routing, issue |
| issue-375 | Two more control keywords, and the second family to carry an argument (2026-09-17): the-loop add-channel <type>@<target> / remove-channel declare the collaboration channel a work item is worked in. Like the collaborator pair they touch neither the session registry nor the graph — they write one section of the work item's portable record, consume the comment that carried them, and arm nothing — and like it they are refused for anyone who is not a named, allowlisted user, because declaring is an action. The argument is parsed by a per-type grammar and refused if it does not fit, so no comment text reaches a store or an API call; a channel another work item holds is refused outright, because a room's messages are attributed to its work item and that must never be a guess | spec, channels, issue |
| issue-371 | Every event the dispatcher finishes with is acknowledged, not only the ones it delivers (2026-09-16). Issue-84 wired reactions to _worker, the thread that delivers an event to a session — so a comment that was an instruction (the-loop add-collaborator @someone wrote the roster and returned), one that was refused, one carrying two keywords, and one suppressed on purpose all ended in silence, leaving the person who typed it unable to tell a granted collaborator from a daemon that is not running. The acknowledgement now hangs off _settle, the seam that already meant "finished with this delivery": a fixed table maps each settled outcome to one of the three states the operator already configures — completed for an executed command, error for a refused or unreadable one, started for the suppressed family (seen, and pending). No new config key, no new event type, record written before the decoration is attempted. Out-of-scope refusals stay silent on every route into them, and the delivered branch is untouched | spec, routing, issue |
| issue-370 | the-loop stopped guessing which pull requests belong to a work item (2026-09-16). The poller's third owner question — GitHub's closing references, the issue-<n> branch, a closing keyword — filed a stranger's pull request under a work item's portable record, and the dispatcher wrote the same guess into the work item's checked-in state as linkedBy: "event"; both are gone, so tracking is what the-loop sessions link-pr recorded and nothing else. To make that record reliable rather than a rule the model had to remember, a PostToolUse hook runs the command when a session creates a pull request, and the tmux runner finally exports THE_LOOP_WORK_ITEM — the variable the plugin's hooks have read since issue-109 and nothing ever set. Delivery routing is unchanged | spec, process-graph, issue |
| issue-368 | The daemon reads a work item's frozen choices from its own checked-in state (2026-09-15) rather than from the operator's portable record, and records each pull request it routes for in that same file through the coupling (graph.pull_request_linked); a closing pull request with an owner has its upstream state recorded there and its nested poll ledger dropped instead of being stamped ended, which is what used to mint it a portable record of its own. A work item frozen before the change keeps its graph section and is routed by it, read and never rewritten | spec, decision-128, cli, issue |
| issue-352 | The coupling stopped reading the work item's checkout (2026-09-12): the spec directory is routing.graph.specDir for every repository the instance drives, the label prefix is the constant loop:, notifications carry no configured roles, and the pre-spawn adoption that wrote a default harness config into a fresh clone is gone with the reader it served | spec, decision-123, issue |
| issue-348 | The receiver gained the repository bound it never had: the list of repositories an instance works with moved out of polling.sources[].repos to a top-level repositories, and every ingress reads it — the receiver drops an undeclared delivery (and undeclared linked refs) as undeclared-repository, above the actor guard; the poller takes its scopes from it; may_target and the kickoff resolve against it. Breaking: config version 0.8.0, the-loop migrate-config moves the lists up (and kickoff.repo with them), an un-migrated config refuses to start. An empty list bounds nothing and says so at start | spec, decision-121, issue |
| issue-332 | A closed item the poller had merely listed leaves the board by itself (2026-09-09): a portable record carrying only poll — excluded from reconciliation by issue-329 because asking every cycle would cost one provider call per unlabelled open item, forever — is now asked once it has been absent from complete listings for sixty cycles' worth of intervalSeconds (measured on the ledger's own lastPolledAt / new closureCheckedAt, so poll --once from cron gets it too), at most twenty per source per cycle, longest-absent first. A closure takes the unchanged close path and the record ends as ended only; a still open or unanswerable answer writes only closureCheckedAt, deferring the question a window. poll.cycle counts the questions as ledger_checks. The tracked set is untouched | spec, decision-115, issue |
| issue-329 | A closed work item is recorded as ended (2026-09-09): the close path stamps an ended section (state, kind, reason, at, source, actor) on the portable record of every tracked item a closed event names — with or without a session on this machine — and a reopened event or a listing that carries the item clears it. Closure reconciliation widened from active sessions to every session record plus every armed, frozen or rostered portable record, skipping stamped ones; a polled closure now carries GitHub's closed_by as its sender, so an authorized closer's cleanup runs on a polling deployment as it does on a webhook one. Both attention surfaces read the stamp and demote the item. Before it, closed items sat under Needs you forever | spec, decision-113, control plane, issue |
| issue-322 | An instance learned which work items are its own (2026-09-08): every instance judged every labelled event identically, so two instances on one repository both spawned for one start. The top-level instance block names the instance and its scope — open (unchanged), addressed (take only a start that names me: the-loop start instance:<name>), locked (take nothing new; scope.workItems is the door) — and one seam in Dispatcher.handle, after linkage and the control parse and before anything is recorded, refuses an event outside the managed set (declared ∪ session record ∪ control record) with a settled dispatch.dropped / control.rejected naming unaddressed, instance-locked, addressed-elsewhere or ambiguous-address, and no reaction, comment or record. An explicit address is authoritative in every mode; a control record now carries instance; the CLI's posted keyword and the announcement name it; a named instance spawns with -e THE_LOOP_INSTANCE=<name> | spec, decision-110, instances, issue |
| issue-307 | A second identity allow-list, per work item (2026-08-31): the-loop add-collaborator @login / remove-collaborator (a tenth and eleventh control keyword, and two CLI commands) grant a GitHub login the right to be input on one work item — their comments reach its session on both ingresses — and nothing else: no control command (so no transitive grant), no spawn (collaborator-no-spawn), no arming, no human gate. The roster is a collaborators section of the work item's portable record, cleared when the item closes; membership is asked only about the refs an event itself named, so a grant does not travel | spec, decision-102, routing, issue |
| issue-279 | A ninth control keyword, the-loop review (2026-08-24): arms and spawns exactly as start does and selects the review loop — and, alone among the keywords, its control record, spawn and session bind to the pull request itself when it is typed on one, not to the PR's linked ticket (pr_work_item on the control path; _on_unmatched takes an explicit target). Configurable at routing.control.keywords.review; an empty string disables the word | spec, process-graph, routing, issue |
| issue-274 | the-loop stopped opening pull requests it could not route events for (2026-08-20): a review comment on a spec PR the-loop's own session opened reached nothing. The router's three inference sources are all absent on such a PR — no closingIssuesReferences (a spec PR must not close its ticket), a loop/<id>-… head branch rather than issue-<n>, and a body that only mentions the issue — and the fourth, the durable session.pr_linked binding, was written only by the routing decision, which needs the linkage to route at all. So the comment resolved to the PR as a work item nobody armed, was refused as awaiting-start, and (since issue-270) settled with will_retry: false: the phase gate the-loop itself asks reviewers to answer was a dead letter, and repairing the linkage afterwards recovered nothing. The session that opens a pull request now records it in the same step — the-loop sessions link-pr, POST /api/v1/sessions/link-pr, or the link_pull_request MCP tool — and the workflow says so beside the rule that every PR is labelled | spec, sessions, issue |
| issue-273 | A work item that begins life as a plain GitHub issue finally starts at phase-selection (2026-08-20): the coupling had declined to start a graph without <specDir>/<id>/ on disk, which the gated work is what creates — so an armed, spawned ticket got two graph.skipped records, no phase checklist, no the-loop execute, and a session that began requirements work on its own. start and context no longer require the directory, and the spawn prompt of an unplaced work item now carries a pending process-state block naming the gate instead of nothing at all | spec, process-graph, issue |
| issue-270 | A comment refused before the start stopped being filed as a delivery still in flight (2026-08-18): pre-start (and paused-session) events are suppressed on purpose and never replayed — the settled product decision — but the poll ledger recorded the refusal as commentAttempts: 1 and left it there, so an operator could not tell "we are still trying" from "we decided not to", and after a restart the poller spent the rest of the budget, declared a terminal delivery failure, posted a notice on the ticket saying the comment never arrived after three attempts, and left a gaveUp record that the next CLI version re-armed — replaying the comment nobody asked to replay. A delivery the dispatcher is finished with is now recorded as settled (suppressed, or consumed as a control command) beside its dedup mark, and the poller resolves such a comment instead of counting it: baselined, never abandoned, one poll.comment_settled record naming the reason. The spawn prompt now tells the session to read the item's whole thread, which is where a refused comment still is | spec, decision-097, issue |
| issue-269 | A branch name stopped being able to invent a work item (2026-08-18): issue-<n> in a head branch resolved to a ref in the pull request's own repository and nothing ever checked that it existed, so in a multi-repository deployment (ticket in one repository, code in another) a ghost became work_items[0] — it absorbed the operator's the-loop start and had a whole session spawned against a ref that 404s, without the spec chain that lives on the real work item. A ref resting on the branch convention alone is now verified before it is acted on, and only a definitive 404 drops it; every other answer keeps it. The record answers first: a live session (its own ref, or a durable PR binding) is the target for control, the start test and the spawn, and where one exists the check is not consulted at all. A polled pull-request comment now names its pull request, so the binding is written and the endpoint chosen on that ingress too; and the announcement's own 404 is recorded as session.work_item_missing instead of a generic best-effort warning | spec, decision-095, 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-258 | How many sessions a work item's pull requests get became the operator's choice again (2026-08-17): issue-253 stated the same-repository collapse as a rule — sessionPerPr: true meant "only a pull request elsewhere splits", and no configuration gave a pull request in the work item's own repository a session of its own. The key is now three-valued — never, cross-repository (the unchanged default) and always — with the legacy booleans still parsing to the first two, so no existing config changes meaning. What did not become optional is decision-088 D2: an endpoint spawns only with a working tree of its own, and a same-repository endpoint's checkout must additionally hold the pull request's head branch (Workspace.prepare(require_branch=True)) — otherwise git's one-branch-per-worktree rule would have handed it a tree silently sitting on the default branch. always is therefore served by workspace.strategy: clone and declines to the single session under worktree | spec, decision-092, routing, issue |
| issue-253 | A work item stopped having two owners (2026-08-16): sessionPerPr gave every pull request delivering a work item its own harness conversation but never its own checkout — _spawn_endpoint spawned with record.cwd, and Workspace.prepare keys both strategies on the work-item slug, so under every configuration a pull request's session ran in the work item session's tree. Two agents, one branch, no lock: on issue-239 they interleaved commits, restarted each other's services and ran the same verification twice against a tree each was changing under the other. Now a pull request in the work item's own repository is the work item's session's — no second spawn — and a pull request in another repository spawns only into a checkout of its own, keyed on its slug, or not at all (session.pr_session_declined) | spec, decision-088, routing, issue |
| issue-243 | A forwarded event stopped carrying GitHub's metadata (2026-08-16): the $payload_excerpt block was a subset of the raw payload — nine containers copied whole, cut at 4,000 characters — so an ordinary comment delivered a 61-character instruction inside 4,014 characters of user objects, reactions and the whole issue, with the cut landing mid-string so the "JSON" did not parse. It is now a field allow-list per container with free text capped per field: a comment is its body, its address and its author's login; an inline comment keeps its anchor ahead of the body; lifecycle and CI events keep what makes them actionable. Measured on the same payload, 4,014 → 203 characters and the whole prompt 6,676 → 2,865. Nothing that acts on an event changed — the gates still read the full payload | spec, decision-086, issue |
| issue-240 | A comment abandoned after polling.maxRetries is now reported on the work item (poll.giveup_reported), naming the comment, the attempts and the recovery — posting it again — instead of leaving a 😕 reaction as the only signal. Best-effort and ledger-first: the notice can fail without changing what was recorded, and it echoes no text from the comment it reports | spec, interactive-sessions, issue |
| issue-246 | The poll ingress reached parity with the receiver on what a comment is (2026-08-16): it read only the IssueComment connection, so an instruction left as a PR review or as an inline review-thread comment was never forwarded — silently, since nothing was read there was nothing to drop or log. The provider now merges all three surfaces into one chronological list (gh pr view --json comments plus paginated gh api …/pulls/<n>/{reviews,comments}), emits each as the event a real webhook would have carried, and forwards an inline comment with its file/line anchor; empty-body and PENDING reviews are dropped as carrying no instruction, an issue costs the one request it always did, and the retained-id cap grew to fit three streams in one ledger | spec, polling, issue |
| issue-228 | The ingresses stopped owning the operator surface (2026-08-14): the poller and receiver are started by the-loop start per polling.enabled / webhooks.ghWebhook.enabled (both default off), the poll command is gone (daemon_entry poller [--once] is the foreground/cron form; the run loop itself is unchanged), a start is proven by the daemon's pidfile lock instead of the removed double-fork handshake, and the receiver now holds its pidfile as a flock like the poller — so daemon_status, the-loop status and a truthful blocking the-loop stop all answer from the lock (the gh-webhook command itself folded away on the owner's PR #229 review, its run loop relocated to the_loop.webhook.daemon). Amended in the same PR (issue-231): service.hostIngresses (default true) runs both ingresses as threads inside the service process, locks kept per-ingress under the service's pid | spec, decision-084, issue, issue-231 |
| issue-225 | An eighth control keyword, do (the-loop do, routing.control.keywords.do): arms and spawns exactly as start at both spawn seams (same durable record, same named-actor authorization, same two-keyword refusal) and selects pdlc-adhoc-loop for the work item's outer walk — a tactical task with no PDLC process, resolved by the GraphLink state-first and then from the portable control record through the shared LOOP_FOR_CONTROL_COMMAND mapping. The existing token boundary already refuses the-loop done/does/docs, so no parser change was needed | spec, decision-083, process-graph, issue |
| issue-201 | Adoption moved to before the spawn (2026-08-10): issue-193 wrote the built-in default from on_spawn, which the dispatcher calls after tmux.spawn — so a session could begin, SessionStart hook included, in a checkout whose .the-loop/ did not exist yet. A public GraphLink.adopt now runs between workspace preparation and the prompt render, and again in the respawn pre-flight, carrying the coupling's own gates (the prepared cwd is not yet proved to be the work item's repository); _adopt stays on the driving actions as an idempotent safety net. The ordering is asserted from inside the spawn call, not after the dispatch returns | spec, decision-073, process-graph, issue |
| issue-197 | The poll ingress stopped letting the work item's author decide whether anybody is listened to (2026-08-10): a comment is judged by its own author, so an authorized user's control comment on an outside contributor's issue arms and forwards; the item's author now gates only whether the poller starts work on it by itself, and an authorized user's recorded arming command satisfies that gate too. The spawn prompt states the work item itself is untrusted content | spec, decision-074, polling, issue |
| issue-199 | The spawning comment reaches the graph (2026-08-10): a spawn that enters a human start node now evaluates that node's exit chain once, with the spawning event's comments attached — so the-loop contribute carrying a goal moves the item to phase-selection without a second command, where before the arming comment (executed by the control path, never forwarded) could reach no gate at all and the item sat at its first node until some unrelated event arrived; agent start nodes and respawns evaluate nothing | spec, process-graph, issue |
| issue-193 | The ingress adopts a repository that never ran /the-loop:init (2026-08-10): the graph coupling writes the-loop's built-in default to .the-loop/harness-config.yaml — naming the work item's owner/repo so originRepo resolves — after the origin-remote ownership proof and before the spec-directory gate, so the session the daemon just spawned has a config to read even on the run whose graph is skipped; recorded as harness.config_scaffolded, never overwriting an existing config, and never for a contribution | spec, decision-073, process-graph, issue |
| issue-186 | A seventh control keyword, cleanup (the-loop cleanup, routing.control.keywords.cleanup): releases a work item's LOCAL resources — every endpoint's tmux session, the workspace checkout, the machine-local session record — keeping the portable record and touching nothing remote. Runs with or without a live session (the retroactive case), disarms the item like a stop, and runs automatically on a closure only when the close event names an authorized actor; otherwise it is deferred and recorded | spec, interactive-sessions, issue |
| issue-185 | A sixth control keyword, contribute (the-loop contribute, routing.control.keywords.contribute): arms and spawns exactly as start at both spawn seams (same durable record, same named-actor authorization, same ambiguity refusal) and selects pdlc-contribution-loop for the work item's outer walk — resolved by the GraphLink state-first, then from the portable control record | spec, decision-070, process-graph, issue |
| issue-183 | Cross-repository linkage (2026-08-09): a qualified closing reference to another repository now routes to the work item there instead of being dropped, so a pull request delivering one repository's share of a multi-repo work item can reach its ticket; the PR's inner loop is addressed by repository as well as number, and an inner-loop prompt's claim command carries --pr-repo | spec, decision-069, process-graph, issue |
| issue-172 | Which session owns a PR's events stopped being recomputed from gh per event (2026-08-07): the work item's single session record now carries its pullRequests[], each an endpoint with its own tmux session and harness conversation (routing.tmux.sessionPerPr, default on — false collapses to the pre-issue-172 single session), spawned lazily and closed individually when its PR closes. Additive resolution; issue-93's derivation and issue-101's close rule unchanged | spec, decision-064, state, issue |
| issue-159 | Stopping and restarting the poller became invisible (2026-08-05): an exclusive lock on the pidfile makes two pollers on one ledger impossible (--once included), poll stop verifies the pid against that lock and waits for the process to exit, each work item's record is persisted as it finishes, a stop ends the cycle after the item in flight (and an interrupted cycle never reconciles closures), and a shutdown hands back the retry budget of dispatches it abandoned | spec, polling, issue |
| issue-156 | Process runner removed; tmux is the only runner (2026-08-05): dispatch always pastes into a tmux-hosted session, the cli-under-process interaction warning went with the runner choice, and the tmux-hosting requirement is unconditional | spec, interactive-sessions, issue |
| issue-142 | routing promoted out from under webhooks.ghWebhook to a top-level key: the block was never the receiver's — the poller reads it verbatim for dispatch and the-loop sessions reads it again — so its scope is now legible from the config's shape rather than from a comment. A relocation only: same options, same defaults, same behaviour, with the cross-command import replaced by one shared accessor and the old path refused rather than ignored (schema 0.4.0) | spec, decision-053, issue |
| issue-136 | Pre-spawn trust reached the checkout it was for: hasTrustDialogAccepted is written on the exact spawn directory under every scope (the gate that decides whether the dialog appears for a repo shipping .claude/settings.json grants has no ancestor walk), so scope now only widens | spec, decision-052, interactive-sessions, issue |
| issue-134 | A spawned session is told where its answers come from instead of guessing: routing.interaction.mode (work-item default, or cli) is rendered into every prompt through $interaction_directive, appended when a custom template omits the placeholder, and reported on session.spawned; artifact iteration in pull-request review became a stated invariant of the loop | spec, decision-051, issue |
| issue-135 | The default execution-control keywords changed shape from colon-joined (the-loop:start-execution) to a short command (the-loop start); the vocabulary, matching semantics and trust boundary from issue-106 are unchanged, and an operator's own explicit keywords override is unaffected | spec, issue |
| issue-123 | The graph coupling stopped sourcing a repo-scoped fact from the operator's machine: routing.graph.specDir became an optional override, so each watched repository's own workflow.specDir was honoured (reversed in issue-352: the CLI reads no harness config and the key is gone; docs/specs is the convention), and a spec-directory skip is recorded as graph.skipped rather than a debug line under a successful delivery | spec, decision-044, process-graph, issue |
| issue-119 | The poll path stopped swallowing a control command that predates first sight: unprocessed, authorized, unambiguous keyword comments are held back from the first-sight baseline and forwarded on the same cycle, so a labelled item whose start comment already existed actually starts | spec, issue |
| issue-128 | Generated state reorganised by portability: one portable/<slug>.json per work item (control + poll sections, read-modify-write) and machine-local session handles under local/, replacing three writer-shaped stores; polling.stateFile retired through the version-gated config migration; the pre-issue-128 locations read forward on upgrade | spec, decision-046, issue |
| issue-111 | The session registry treats <root>/sessions/ as shared state: listings read only <slug>.json files it wrote, keeping the corrupt-entry warning meaningful | spec, issue |
| issue-106 | Execution control: four declared keywords (the-loop:start-execution, …) an authorized user steers with, the auto-execute label demoted to necessary but not sufficient (routing.control.requireStartCommand, default on), paused sessions, CLI parity with the same paper trail, and one state.root for everything the CLI generates | spec, decision-040, issue |
| issue-104 | The loop-prevention marker gained a producer-side helper (mark_self_authored) applied to the daemon's own comments — the session announcement no longer re-enters the session it announces | spec, decision-031, issue |
| issue-101 | A work item may be delivered by several PRs: a pull_request closed event now ends only the session registered against that PR itself, leaving a linked issue's session (and its checkout) running until the issue's own close | spec, decision-039, issue |
| issue-94 | A finished work item now ends its session on both ingress paths: the poller reconciles active sessions against each successful listing and closes the ones whose item is closed/merged upstream; the receiver treats issues/closed like pull_request/closed instead of delivering it into the conversation | spec, issue |
| issue-93 | An event on a PR resolves the PR's linked issues first (closingIssuesReferences + branch/keyword conventions, incl. PR conversation comments delivered as issue_comment), so PR activity reuses the linked issue's session instead of spawning a second one | spec, decision-036, issue |
| issue-90 | Pre-seed the harness config before spawning (routing.harnessTrust) so spawned sessions stop stalling on the workspace-trust dialog / bypass-permissions disclaimer | spec, decision-037 |
| issue-84 | Dispatch-lifecycle emoji reactions (routing.reactions, opt-in): 👀 started / 🎉 completed / 😕 error on the triggering comment or issue/PR, best-effort via gh, no-op where unsupported | spec, issue |
| issue-113 | Dispatch now drives the process graph: a spawn enters the start node (so the phase labels finally get written) and a delivered event advances it with the comments attached; routing.graph toggles it | spec, process-graph, issue |
| issue-63 | webhooks.* moved out of the per-repo plugin config into an independent, repo-agnostic CLI config | spec, decision-032 |
| issue-64 | Added the self-reply marker guard (drops the-loop's own comments/reviews before dispatch, on both trigger paths, so it never resumes a session on its own reply) | decision-031 |
| issue-80 | Bounded per-event retry policy on the poll path (polling.maxRetries, default 3): stop baselining failed spawns/comments as processed, retry each cycle, then log poll.spawn_failed/poll.comment_failed and ignore; a new comment retriggers | spec, issue |
| issue-32 | Added the tmux runner option for spawned sessions (dispatch via paste-injection; PR-close kills the tmux session) | spec, decision-021 |
| issue-15 | Added session registry, event→session routing and harness resume (receiver shipped in v0 gained --route) | spec, decision-016 |
| issue-1 | Shipped the HMAC-verified gh-webhook receiver (v0) | spec, decision-005 |