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 added | Where it lives | Consumed by |
|---|---|---|---|
| F1 | An inner loop belongs to a repository, not only to a number | pr-loops/<owner>__<repo>/pr-<n>/ | graphlink, bootstrap, graph CLI, await-inner-loops |
| F2 | A pull request may close a ticket in another repository | router.linked_work_items | extract_work_items → the whole ingress |
| F3 | Each work item chooses its outer-loop surface | one phase-selection checklist row → GraphState.surface | the runtime → assignment + prompt context |
| F4 | A work item may declare the repositories it contributes to | execution-log.md front matter repos: | await-inner-loops |
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 --> AArchitecture
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.
| Thing | Repository | Path |
|---|---|---|
| The ticket, the phase label, the outer loop | origin | — |
The spec chain (requirements.md … execution-log.md) | origin | <specDir>/<id>/ |
| The outer loop's state | origin | <specDir>/<id>/graph-state.json |
| An origin-repo PR's inner loop | origin | <specDir>/<id>/pr-loops/pr-<n>/ |
| A contributing repo's PR inner loop | origin | <specDir>/<id>/pr-loops/<owner>__<repo>/pr-<n>/ |
| The code a PR changes | that 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)
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:
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 allA 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.
C3 — threading the PR's repository through the runtime (bootstrap, graphlink, core, CLI)
One optional argument, added at each layer in the same position, defaulting to "":
| Layer | Change |
|---|---|
graph/bootstrap.build_runtime | pr_repo: str = "" → state_subpath = f"pr-loops/{key}/pr-{n}" |
graphlink.GraphLink._guarded / _build_runtime | pr_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_close | derive 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:
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 subsetOrder 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 thehttps://github.com/octo/infra/issues/12form) now yields a ref in that repository instead of being dropped; - a
closingIssuesReferencesentry that names its repository (nameWithOwner,{name, owner.login}, or a parseableurl) is honoured; one that does not still defaults to the event's repository; - the branch convention stays same-repo —
issue-12on 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).
C5 — the surface, declared at phase-selection (selection, state, runtime, assignment, graphlink)
(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:
**Where should the outer loop happen?** …
- [ ] `outer-loop-on-pull-request` — on a pull request in this repository.| Piece | Change |
|---|---|
hooks/selection.py | posts 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.py | GraphState.surface — additive, absent in every existing state file, "" reading as the default |
graph/runtime.py | records result.data["surface"] beside the frozen graph, and passes state.surface into every hook context (entry and exit) |
graph/contract.py | HookContext.surface — the one channel a hook reads it through |
hooks/assignment.py | one 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.py | GraphContext.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:
---
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:
{
"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):
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 repositoryError handling
| Condition | Behaviour | Why |
|---|---|---|
Repository value with /-escapes, .., or an empty segment | ValueError from repo_state_key; the graph coupling logs it and the delivery still happens | Fail closed at the path boundary; a graph fault must never cost a delivery (_guarded's existing contract) |
--pr-repo without --pr | usage error, no state written | A repository does not identify a loop |
| Declared repo with no inner loop | wait, naming the repository | R4.2 — a missing contribution is not an absent one |
Declared repo but ticketing.github unknown | wait, naming the missing config | Guessing which loop was meant is how a gate passes on the wrong evidence |
| Unreadable inner state | counts as unfinished (unchanged) | issue-124's silent-pass shape |
| Surface row unticked, absent, or a checklist the-loop could not read | resolves to the work item | R2.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_keyaccepts[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-stylea\..\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 aWorkItemRef, 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,ValueErroror 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 case Mechanism Negative test 1 — hostile repository name in a payload repo_state_keyraisestest_repo_state_key_rejects_traversal2 — hostile --pr-repoargumentsame function at the CLI boundary test_graph_pr_repo_argument_is_validated3 — foreign PR closing an unarmed work item _awaiting_start(unchanged)test_cross_repo_link_does_not_arm_a_work_item4 — declared repo that never gets a PR await-inner-loopswaitstest_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:
- 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.
- 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 atphase-selectionand frozen there. A repository-wide key would be wrong for half of any repository's work items. - 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.
- 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".
- The declared-repos gate is opt-in by declaration. Inferring the repository set from
tasks.mdprose 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-feedbackhook when a human gate approves with comments.