Skip to content

Design: multi-repo work items — the outer loop stays in the origin repo, and each work item chooses its surface

Phase 2. Derives from requirements.md. Ticket: #183.

Overview

Three small facts are added to a model that already has the right shape. the-loop already separates the work item's loop from each pull request's loop (decision-065); what it lacks is (1) a repository qualifier on an inner loop, (2) a route from a pull request in another repository back to the ticket, and (3) a name for where the outer loop's artifacts are iterated. Nothing in the phase graph changes: no node, no edge, no artifact.

#Fact addedWhere it livesConsumed by
F1An inner loop belongs to a repository, not only to a numberpr-loops/<owner>__<repo>/pr-<n>/graphlink, bootstrap, graph CLI, await-inner-loops
F2A pull request may close a ticket in another repositoryrouter.linked_work_itemsextract_work_items → the whole ingress
F3Each work item chooses its outer-loop surfaceone phase-selection checklist row → GraphState.surfacethe runtime → assignment + prompt context
F4A work item may declare the repositories it contributes toexecution-log.md front matter repos:await-inner-loops
mermaid
flowchart LR
  E["webhook / poll event<br/>PR #7 in octo/infra<br/>body: Closes acme/app#183"]
  R["router.linked_work_items<br/>(F2 — cross-repo)"]
  D["dispatcher<br/>endpoint = PR #7"]
  G["graphlink.on_pr_*<br/>pr_repo=octo/infra (F1)"]
  S["acme/app checkout<br/>docs/specs/issue-183/pr-loops/octo__infra/pr-7/graph-state.json"]
  A["await-inner-loops<br/>repos: [octo/infra, …] (F4)"]
  E --> R --> D --> G --> S --> A

Architecture

The three locations, and which repository each is in

The invariant the whole design turns on: the work item's spec directory is in the origin repository, and every inner loop's state is under it — including inner loops for pull requests in other repositories. The daemon already drives a work item from the origin repository's checkout (_checkout_belongs_to proves it via the origin remote), so writing a foreign PR's inner state there needs no second checkout and no new trust check.

ThingRepositoryPath
The ticket, the phase label, the outer looporigin
The spec chain (requirements.mdexecution-log.md)origin<specDir>/<id>/
The outer loop's stateorigin<specDir>/<id>/graph-state.json
An origin-repo PR's inner looporigin<specDir>/<id>/pr-loops/pr-<n>/
A contributing repo's PR inner looporigin<specDir>/<id>/pr-loops/<owner>__<repo>/pr-<n>/
The code a PR changesthat PR's repository

Two shapes rather than one, on purpose (R1.4): every work item that already has pr-loops/pr-<n>/ keeps it byte-for-byte, so this change needs no state migration and a half-finished work item is not stranded by an upgrade.

Components & interfaces

C1 — repo_state_key and the repo-qualified state directory (graph/hooks/loops.py)

python
def repo_state_key(repo: str) -> str:
    """`owner/repo` (or `host/owner/repo`) → `owner__repo`; ValueError otherwise."""

def inner_loop_state_dir(spec_dir: Path, pr_number: int, repo: str = "") -> Path:
    """`<spec_dir>/pr-loops/pr-<n>`, or `…/pr-loops/<key>/pr-<n>` when `repo` is given."""

repo_state_key is the trust boundary of R1.6 and abuse cases 1–2: at least two segments, each matching [A-Za-z0-9._-]+ and neither . nor ... It raises rather than sanitizes — a repo name silently rewritten into a valid one would file one repository's inner-loop state under another repository's name, which is worse than a refusal. repo="" is the origin-repo case and returns the shipped path.

C2 — await-inner-loops gains declared repositories (graph/hooks/loops.py)

The hook keeps its shape — a pure read of checked-in files, no network — and gains one input and one failure mode:

text
declared = execution-log front matter `repos:`        (F4; [] when absent)
started  = every pr-loops/**/graph-state.json         (both layouts)

PASS   when every started loop is at `complete` AND every declared repo has ≥1 started loop
WAIT   naming the unfinished loops and/or the declared repos with no loop at all

A declared repo is matched to loops by its repo_state_key: the origin repository's key matches the top-level pr-<n> loops, every other key matches its own subdirectory. The origin repository comes from ticketing.github via the runtime config (config["originRepo"]). When that is unknown, a declared repo with no subdirectory waits and the message says why — fail closed, and say what would fix it, rather than guessing which top-level loop was meant.

repos: absent ⇒ declared = [] ⇒ today's behaviour exactly (R4.3), including the vacuous pass on zero loops.

One optional argument, added at each layer in the same position, defaulting to "":

LayerChange
graph/bootstrap.build_runtimepr_repo: str = ""state_subpath = f"pr-loops/{key}/pr-{n}"
graphlink.GraphLink._guarded / _build_runtimepr_repo param; the state lock directory uses the same path, so two repos' PR #7 do not contend on one lock
graphlink.on_pr_spawn / on_pr_event / pr_context / on_pr_closederive it: pr.path if pr.path != work_item.path else "" — the caller passes refs it already holds, so no new plumbing reaches the dispatcher
core/graphs.{check,complete,advance,force,skip,show}pr_repo: str = "", passed to build_runtime
the-loop graph <verb> --pr-repo <owner>/<repo>six subparsers, added by one helper alongside --pr

--pr-repo without --pr is a usage error: a repository does not identify a loop.

C4 — cross-repo linkage (webhook/router.py)

linked_issue_numbers returns numbers, which is why it had to drop cross-repo references — a number alone cannot say which repository it belongs to. It is replaced by a ref-returning sibling and kept as a same-repo wrapper:

python
def linked_work_items(entity: dict, owner: str, repo: str, host: str = "") -> List[WorkItemRef]
def linked_issue_numbers(entity: dict, owner: str, repo: str) -> List[int]   # same-repo subset

Order and sources are unchanged (GitHub's own closingIssuesReferences, then the issue-<n> branch convention, then closing keywords in the body). What changes:

  • a qualified closing keyword (Closes octo/infra#12, or the https://github.com/octo/infra/issues/12 form) now yields a ref in that repository instead of being dropped;
  • a closingIssuesReferences entry that names its repository (nameWithOwner, {name, owner.login}, or a parseable url) is honoured; one that does not still defaults to the event's repository;
  • the branch convention stays same-repo — issue-12 on a branch says nothing about a repository.

extract_work_items then emits those refs before the PR's own ref, exactly as it emits linked numbers today (decision-036's ordering is untouched).

What this does not widen: which events reach the router. The ingress is the operator's webhook receiver and poll sources; an event from a repository nobody watches never arrives, and an unarmed work item still drops at _awaiting_start (abuse case 3).

(Revised in review, PR #184. The first draft made this a repository config key, workflow.outerLoop.surface. It is now the work item's own declaration — see D7 of decision-069 for why, and § Trade-offs below.)

The phase-selection checklist gains one row that is not a phase:

text
**Where should the outer loop happen?** …

- [ ] `outer-loop-on-pull-request` — on a pull request in this repository.
PieceChange
hooks/selection.pyposts the row in its own section; _parse_surface(body) reads it from the same body the phase selection is read from; _parse_selection excludes the token before phase resolution, so an unticked row is neither a skip nor a refusal (R2.9); the confirmation names the resolved surface; _frozen_graph carries it
graph/state.pyGraphState.surface — additive, absent in every existing state file, "" reading as the default
graph/runtime.pyrecords result.data["surface"] beside the frozen graph, and passes state.surface into every hook context (entry and exit)
graph/contract.pyHookContext.surface — the one channel a hook reads it through
hooks/assignment.pyone line on an outer-loop node that produces an artifact, naming the resolved surface; an inner-loop node's line names its pull request and says the choice does not exist there (R2.8)
graphlink.pyGraphContext.surface from state.surface, rendered into the event prompt; the claim command gains --pr-repo for a cross-repo loop

Resolution is a two-value function with a default, and the default is the work item: pull-request only when the row is ticked in the body an authorized user signed. Nothing in harness-config.yaml or cli-config.yaml participates.

C6 — what stays a rule rather than becoming code

R2.4/R2.5 and all of R3 (where artifacts are iterated; the landing pull request) are stated in SKILL.md, reference/workflow.md and reference/collaboration.md and gated only through the record every work item already keeps (## Pull requests in the execution log, which the reviewer-briefing node gates). the-loop does not open pull requests — the agent does — so a "landing PR" hook would be code with no caller (reference/minimalism.md).

Data models

execution-log.md front matter gains one optional key:

yaml
---
type: execution-log
workItem: issue-183
phase: implementation
status: in-progress
repos:                        # OPTIONAL. The contributing repositories this work item
  - MadaraUchiha-314/the-loop #   raises pull requests in — one inner loop each. Absent
  - octo/infra                #   means single-repo, and the gate behaves as before.
---

graph-state.json gains one optional field, written by the same signed reply that freezes the phase selection — "" on an existing state file reads as the default, so nothing has to be migrated:

json
{
  "surface": "work-item",
  "decisions": {"phase-selection": {"surface": "work-item", "graph": {"surface": "work-item"}}}
}

pr-loops/ layout (in the origin repository, under the work item's spec directory):

text
docs/specs/issue-183/
├── graph-state.json                      ← the outer loop
└── pr-loops/
    ├── pr-184/graph-state.json           ← a PR in the ORIGIN repository
    └── octo__infra/pr-7/graph-state.json ← a PR in a contributing repository

Error handling

ConditionBehaviourWhy
Repository value with /-escapes, .., or an empty segmentValueError from repo_state_key; the graph coupling logs it and the delivery still happensFail closed at the path boundary; a graph fault must never cost a delivery (_guarded's existing contract)
--pr-repo without --prusage error, no state writtenA repository does not identify a loop
Declared repo with no inner loopwait, naming the repositoryR4.2 — a missing contribution is not an absent one
Declared repo but ticketing.github unknownwait, naming the missing configGuessing which loop was meant is how a gate passes on the wrong evidence
Unreadable inner statecounts as unfinished (unchanged)issue-124's silent-pass shape
Surface row unticked, absent, or a checklist the-loop could not readresolves to the work itemR2.3 — the default opens nothing, which is the safe direction

Security design

Each boundary from requirements.md § Security considerations, with the mechanism that enforces it and the negative test that proves it.

  • AuthN/AuthZ. Unchanged. Control keywords and human gates still resolve against routing.authorizedUsers; nothing here reads or writes that list. The cross-repo route changes which work item an event names, never who may start or approve one.

  • Input validation — repository → path (boundary 2). repo_state_key accepts [A-Za-z0-9._-]+ segments only, rejects ./.., and requires at least two segments. Every path-building call site goes through it: inner_loop_state_dir, build_runtime, graphlink's lock directory. Negative tests: ../../etc, a//b, a/.., a, "", and a Windows-style a\..\b (rejected — backslash is outside the class).

  • Input validation — payload → ref (boundary 1). A qualified closing reference is parsed by the existing _CLOSING_KEYWORD_RE (already anchored to GitHub's own owner/repo grammar) and materialises only as a WorkItemRef, which is a dataclass of parsed fields — no payload string reaches a path, a command, or a prompt through this route. The routing decision remains "does a registered, armed session exist for this ref"; nothing here registers or arms anything.

  • Injection surfaces. No new subprocess, no new SQL, no new template. The assignment and prompt lines added in C5 are composed from the-loop's own vocabulary plus the resolved surface (one of two literals) and the PR's already-parsed owner/repo — the same "no payload text in a graph message" rule R3.6 states.

  • The checklist row inherits the gate's authorization. The surface now arrives in a comment, so its trust boundary is phase-selection's, unchanged: only an authorized user's execute reply is read, the-loop's own comments are dropped before authorization is considered, and the answer is frozen — a later edit of the checklist changes nothing. The parsed value is one of two literals, never the comment's text.

  • Secrets handling. None added. No new file records anything but a repository name and a pull-request number.

  • Least privilege. Unchanged: the daemon writes only inside the origin repository's spec directory. A foreign PR's inner state is written there, so no credential, remote or checkout for the contributing repository is required by any code in this work item.

  • Fail-closed behaviour. Every ambiguity above resolves to wait, ValueError or the default — never to "proceed as if it were fine". For the surface the default direction is the one that opens nothing: an unreadable checklist means the work item, not a pull request.

  • Abuse-case coverage.

    Abuse caseMechanismNegative test
    1 — hostile repository name in a payloadrepo_state_key raisestest_repo_state_key_rejects_traversal
    2 — hostile --pr-repo argumentsame function at the CLI boundarytest_graph_pr_repo_argument_is_validated
    3 — foreign PR closing an unarmed work item_awaiting_start (unchanged)test_cross_repo_link_does_not_arm_a_work_item
    4 — declared repo that never gets a PRawait-inner-loops waitstest_await_waits_for_a_declared_repo_with_no_loop

Testing strategy

Unit tests carry this work item: every added behaviour is a pure function of files and arguments (repo_state_key, inner_loop_state_dir, await_inner_loops, linked_work_items, outer_loop_surface, render_assignment). Two integration scenarios cover the seams that unit tests cannot: a cross-repo pull request routing to its work item and walking an inner loop whose state lands under the origin repo's spec directory, and the outer implementation gate holding until a declared repository's loop finishes. The parity suites (test_docs_parity, test_harness_config, test_graph_parity) are the regression net for the config/doc surface. The executable detail is testing-plan.md.

Trade-offs & decisions

Recorded as decision-069. In short:

  1. Two state layouts instead of one. A single repo-qualified layout would be tidier; migrating live work items to get it is not worth the tidiness.
  2. The surface is neither harness config nor CLI config (revised in review, PR #184). Where a human sits is the operator's machine (interaction.mode, decision-051); where this work item is collaborated on is the work item's own, declared at phase-selection and frozen there. A repository-wide key would be wrong for half of any repository's work items.
  3. decision-051 §5 is amended, not overturned. Its invariant becomes: artifacts are iterated on a durable, reviewable surface — the pull request or the work item — never in a terminal. The configuration it refused (specs discussed in scrollback) is still refused.
  4. Cross-repo linkage is unconditional, not a new config toggle. The ingress and the arming gate already bound it, and a toggle would be a second name for "the operator configured this repository".
  5. The declared-repos gate is opt-in by declaration. Inferring the repository set from tasks.md prose would make a gate depend on parsing prose — the thing the graph exists to avoid.

Open questions

None.

Review comments

Appended by the-loop's record-feedback hook when a human gate approves with comments.

Released under the MIT License.