loopctl-mcp-server 2.105.0 → 2.106.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -514,17 +514,17 @@ The order to wire it up, the stage machine these tools move a story through, and
514
514
  | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
515
515
  | `runner_revoke` | Revoke a runner (`DELETE /api/v1/runners/:id`): its credential stops authenticating and its live socket is disconnected. The undo for `runner_enroll`. Idempotent. Required: `id`. |
516
516
  | `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report: `reported_in_flight` is a hint (the runner counts sessions, loopctl counts reservations), while `reported_max_sessions` is what `max_sessions` is copied from on every join (contract 1.13.0), capped at `enrolled_max_sessions` which is also returned. The two can differ in EITHER direction. `max_sessions` BELOW it happens when the machine is declaring above its enrolled ceiling (`enrolled_max_sessions` is what makes that one visible in the payload; the rest are not), when the declaration write has not LANDED — it can fail under lock contention on the runner row and is retried on that socket's 30-second recheck, downward only — or when the socket joined a node older than contract 1.13.0 and has not reconnected since. `max_sessions` ABOVE it happens when the machine declared `0`, held as `1` because the column is 1..64 while this field shows the raw `0`, and again when a declaration write has not landed and the row keeps an older, larger number. A `reported_max_sessions` of 0 means the machine is taking no work and `place_dispatch` refuses a new placement on it, like `draining`. `branch_prefixes` is what the machine declared it ACCEPTS as a branch-name prefix (runner contract 1.14.0): loopctl derives a branch starting with the FIRST entry, and `[]` means it declared no constraint. Read it when `place_dispatch` refuses 409 `no_conforming_branch`, and when a connected machine refuses every dispatch with `branch_not_allowed` — a runner that ENFORCES a prefix and declares none shows `[]` here, which is that misconfiguration made visible. Per-CONNECTION, like `kinds`. `usage_exhausted_until` (runner contract 1.17.0) is until when the machine's SUBSCRIPTION is exhausted, or null: while it is in the future nothing is placed on it and `place_dispatch` refuses 409 `runner_exhausted`. It is the EFFECTIVE value, the latest of the machine's own and every machine's sharing its account, so a machine that never reported anything can show one; capacity returns at that instant on its own. Presence converges only within a cluster. |
517
- | `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. 409 `runner_exhausted` when the machine's subscription (its own, or one it shares an account with) is exhausted — also refused before the mint, and the body carries `usage_exhausted_until`, when it clears on its own. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `base_branch`, `wall_clock_seconds`, `max_turns`, and `branch` — **which you should not send**. Since runner contract 1.14.0 loopctl DERIVES the branch from the story number and an id fragment, behind a prefix the target runner declared it accepts (`branch_prefixes`, visible on `runner_pool`), which is a per-machine fact only the server can read. A branch you name is never rewritten, only judged, and it must satisfy three things: it must be a valid git ref name (422 `invalid_branch_name`, which also refuses a non-string such as `null`), it must start with one of that machine's declared prefixes (422 `branch_not_allowed`), and it must END WITH THIS STORY'S OWN SUFFIX (422 `branch_not_unique`) so that two stories on one repository can never be given one branch — you may choose the prefix, you may not drop the suffix. Nothing is claimed on any of them, and the fix is to drop the field. `base_branch` is judged as a git ref name too (422 `invalid_branch_name`): it reaches git on the runner just as `branch` does. It is NOT story-unique — every dispatch cutting from `master` is the normal case. A retry naming a different `branch` from the one this dispatch was already sent on is 422 `branch_conflict`: the branch is recorded at the first push and re-sent verbatim, because a session may be running on it right now. 409 `no_conforming_branch` is the machine's problem rather than the request's: it declares prefixes and none of them can produce a valid branch name carrying the story number and id fragment that keeps two stories off one branch, so nothing was claimed and an operator has to fix `branch_prefixes` on that box — the body echoes the declared prefixes. A retry carrying a `dispatch_id` loopctl already holds is never refused for either prefix reason: its claim is already standing, so the declaration only steers the name. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `pending` or `contracted` (a pending one is contracted by the placement), its dependencies met, with its stage at `queued`. A retry also moves the claim's deadline to now + its `wall_clock_seconds` + `DISPATCH_LEASE_GRACE_SECONDS` (never earlier than the one already sent), and 409 `dispatch_claim_ended` means the claim that `dispatch_id` was placed under has ended — nothing was pushed or written; place again with a new `dispatch_id`. Requires `LOOPCTL_USER_KEY`. |
517
+ | `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. 409 `runner_exhausted` when the machine's subscription (its own, or one it shares an account with) is exhausted — also refused before the mint, and the body carries `usage_exhausted_until`, when it clears on its own. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `base_branch`, `wall_clock_seconds`, `max_turns`, and `branch` — **which you should not send**. Since runner contract 1.14.0 loopctl DERIVES the branch from the story number and an id fragment, behind a prefix the target runner declared it accepts (`branch_prefixes`, visible on `runner_pool`), which is a per-machine fact only the server can read. A branch you name is never rewritten, only judged, and it must satisfy three things: it must be a valid git ref name (422 `invalid_branch_name`, which also refuses a non-string such as `null`), it must start with one of that machine's declared prefixes (422 `branch_not_allowed`), and it must END WITH THIS STORY'S OWN SUFFIX (422 `branch_not_unique`) so that two stories on one repository can never be given one branch — you may choose the prefix, you may not drop the suffix. Nothing is claimed on any of them, and the fix is to drop the field. `base_branch` is judged as a git ref name too (422 `invalid_branch_name`): it reaches git on the runner just as `branch` does. It is NOT story-unique — every dispatch cutting from `master` is the normal case. A retry naming a different `branch` from the one this dispatch was already sent on is 422 `branch_conflict`: the branch is recorded at the first push and re-sent verbatim, because a session may be running on it right now, and a retry naming a different `base_branch` from the recorded one is 422 `base_branch_conflict`, because the merge gate judges against the recorded base. 409 `no_conforming_branch` is the machine's problem rather than the request's: it declares prefixes and none of them can produce a valid branch name carrying the story number and id fragment that keeps two stories off one branch, so nothing was claimed and an operator has to fix `branch_prefixes` on that box — the body echoes the declared prefixes. A retry carrying a `dispatch_id` loopctl already holds is never refused for either prefix reason: its claim is already standing, so the declaration only steers the name. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `pending` or `contracted` (a pending one is contracted by the placement), its dependencies met, with its stage at `queued`. A retry also moves the claim's deadline to now + its `wall_clock_seconds` + `DISPATCH_LEASE_GRACE_SECONDS` (never earlier than the one already sent), and 409 `dispatch_claim_ended` means the claim that `dispatch_id` was placed under has ended — nothing was pushed or written; place again with a new `dispatch_id`. Requires `LOOPCTL_USER_KEY`. |
518
518
  | `story_stage` | **Where a story is in the delivery machine** (`GET /api/v1/stories/:id/stage`): stage, `claim_epoch`, `lock_version`, `attempts`, the runner holding it, and the escalation reason when parked. `stage: null` means the loop has never touched it. This is how you watch a run, and the only way to see why a stage report was refused `stale_stage`. `escalation_reason` is untrusted session text. Required: `story_id`. |
519
519
  | `resolve_escalation` | **Move an escalated story off `escalated`, as a human** (`POST /api/v1/stories/:id/stage/resolve`): `to` is `queued` (work it again), `done` or `failed`. The other half of `escalate_story` — without it a parked story stays parked for ever. Requires `LOOPCTL_USER_KEY` on a key no dispatch minted: a session cannot resolve the escalation it raised. Optional: `reason`. |
520
520
  | `thread_get` | **Read a story's change thread** (`GET /api/v1/stories/:id/thread`, any role): its latest page of checkpoints (`checkpoints_truncated` is true when older ones exist), and its entries in `seq` order, paged with `after_seq` / `limit` (the response's `next_after_seq` is the next page's `after_seq`). Every entry body is untrusted text (`body_untrusted: true`). |
521
521
  | `thread_checkpoint` | **Record a checkpoint on the story you hold** (`POST /api/v1/stories/:id/thread/checkpoints`): a pushed commit plus your reasoning in `note`, sent on the key `claim_story` claims with (`LOOPCTL_API_KEY` when set, else `LOOPCTL_AGENT_KEY`). Refusals: 409 `not_claimant`; 409 `stale_claim_epoch` or `claim_not_live` (your claim no longer accepts work: it ended, its lease lapsed, you requested review or reported it); 409 `checkpoint_conflict` (recorded under your claim with another tree or note); 422 for a malformed sha, a commit and tree of different object formats, an empty or oversized note (bytes), or a note carrying a credential (`secret_blocked`). Idempotent on (`commit_sha`, `claim_epoch`). Required: `story_id`, `claim_epoch`, `commit_sha`, `tree_sha`. |
522
522
  | `thread_entry` | **Write a message on a story's thread** (`POST /api/v1/stories/:id/thread/entries`): kind `message`, from any principal, optionally naming a `checkpoint_id` of this story. Findings, fixes and verdicts are written by a review dispatch (US-45.3), and review requests by the request-review flow; all are refused here. A credential in the body or key is 422 `secret_blocked`. Idempotent per author on `idempotency_key` for the same write; a different entry on the same key is 409 `idempotency_key_reused`, and keys starting `loopctl:` are reserved. `principal` picks the key: agent (default, the key `claim_story` uses), orchestrator or user. |
523
523
  | `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **A delivery story then goes to `escalated`, not back to the queue** (loopctl US-44.4): an operator taking a story back is a human decision, so a release that leaves the stage row at `queued` escalates it over `operator_released` in the same transaction, spending no attempt against the retry ceiling — it is not left in the queue behind the human's back. Put it back to work with `resolve_escalation` `to: queued`, which releases (a no-op by then) AND re-contracts. A story with no delivery stage row is simply left `pending`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Run again on an already-pending story it is still an operator's release: a row stranded behind the story's claim epoch is rebound, an in-flight row is requeued, and a row then at `queued` — including one already sitting there — is escalated over `operator_released`, as on the first run; a row already escalated, or anywhere else at that epoch, is left alone. Every 500 means the whole release rolled back and the story is still claimed: `audit_chain_append_failed` (the escalation's chain entry was refused) is a server-side condition a re-run meets again until an operator acts; `force_unclaim_failed` (the server log names the step) — call it again. A 422 means the release write itself was rejected. No request body. Required: `story_id` (refused locally if it is not a UUID). |
524
- | `merge_precondition` | **Run the merge gate over a story's real pull request** (`POST /api/v1/stories/:id/merge-precondition`). Both delivery gates run again over the diff that exists, plus custody, the 12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy exclusion; the story must be at stage `ci`. **Gate A reads what triage persisted, never the caller**: the lens verdicts recorded with the story's most recent triage, or a human's re-queue of a Gate A escalation — `gate_a_inputs` on the answer says which, and `missing` refuses with `gate_a_inputs_missing`. This tool sends no trio. Decisions: `allow` (recorded against the head; the only thing that licenses a merge), `refuse` (the story is escalated before this returns), `already_merged`, `head_moved`, `unevaluated` (503 with `Retry-After`). Requires an orchestrator- or user-ROLE key (`exact_role: [:orchestrator, :user]`, so an agent key is 403'd); `LOOPCTL_ORCH_KEY` is sent when set, else `LOOPCTL_API_KEY`. 403 `custody_tier_required` without a human anchor, 404 for an unknown story, 422 when the story is not at `ci`. Required: `story_id` (refused locally if not a UUID) and `claim_epoch` (a non-negative integer, refused locally otherwise); optional `effect_proof`. |
525
- | `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, and `target_epic_id` — **omitting the epic is not a neutral default**: with no epic every triaged report is escalated to a human and every record stays `pending_triage` until `intake_source_update` names one. Requires `LOOPCTL_USER_KEY`. |
526
- | `intake_source_list` | List the tenant's intake sources (`GET /api/v1/intake/sources`): `id` (the webhook URL is `/api/v1/intake/github/<id>`), `project_id`, `repo_full_name`, `base_branch`, `target_epic_id`, `revoked_at`, timestamps. **The webhook secret is not here and is not anywhere** — it is returned once by `intake_source_enroll`, so this is not how to recover one. Use it to find the source behind a 409 `no_intake_source` or `ambiguous_intake_source`, to check a repository's `base_branch` before placing work, and to identify a source left behind by an enrolment whose outcome was unknown. Optional: `include_revoked`. Requires `LOOPCTL_USER_KEY`; the only one of the four that does not also need a human-anchored tenant. |
527
- | `intake_source_update` | **Set where a source's work lands** (`PATCH /api/v1/intake/sources/:id`): `target_epic_id`, the epic triaged stories are created in, and `base_branch`, the branch every dispatch for this repository is cut from. Presence decides — a field you do not name is left exactly as it was, and naming neither is 422 `nothing_to_update`. **Do not send `target_epic_id: null` to mean "not changing this":** an explicit null is the only way to CLEAR the epic, which returns the source to escalating every report instead of filing a story. `base_branch` has no cleared state and a null there is refused locally. This is how a source already pointed at the wrong trunk is corrected — `intake_source_enroll` takes `base_branch` itself, so a `main` repository no longer needs a second call — and the remedy for a source with no epic — every record from it stays `pending_triage` and is retried until one is named, then they promote on the next run with nothing lost. Revoked sources are 404, not a no-op: revoking clears the target so the epic can be deleted, and repointing one would put that block back. 422 when the epic is not in this source's project. Required: `source_id` (refused locally if it is not a UUID — the server answers the same 404 for malformed and unknown). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
524
+ | `merge_precondition` | **Run the merge gate over a story's real pull request** (`POST /api/v1/stories/:id/merge-precondition`). Both delivery gates run again over the diff that exists, plus custody, the 12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy exclusion; the story must be at stage `ci`. **Gate A reads what triage persisted, never the caller**: the lens verdicts recorded with the story's most recent triage, or a human's re-queue of a Gate A escalation — `gate_a_inputs` on the answer says which, and `missing` refuses with `gate_a_inputs_missing`. This tool sends no trio. Decisions: `allow` (recorded against the head; the only thing that licenses a merge), `refuse` (the story is escalated before this returns), `already_merged`, `head_moved`, `unevaluated` (503 with `Retry-After`). **Thread mode** (the current claim was placed under a source with `mode: thread` — the mode is bound to the dispatch at placement): no pull request; the gate judges the latest checkpoint recorded under the story's CURRENT claim, on the branch that claim's dispatch ran on, and adds refusals `empty_change`, `checkpoint_tree_mismatch`, `no_checkpoint_recorded` and `claim_ended` (the claim was released after an earlier one recorded checkpoints); the source's runners must be at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; the branch is judged first, and a missing branch in a readable repository (`branch_missing`), a head nobody recorded (`branch_head_unrecorded`), an earlier checkpoint (`branch_head_regressed`), or a head the stage row did not record is `head_moved` back to implementing while the claim is LIVE, and a refusal naming `claim_not_live` (escalated) when it is not; an unreadable repository refuses `pull_request_unavailable`; the diff judged is the three-dot diff against the merge base, so a base that moved on is not a refusal, and a checkpoint the base already contains (its branch deleted or not) is `already_merged` only under a recorded allow naming it, otherwise `checkpoint_on_base_without_allow`; a thread loopctl could not read refuses `thread_unreadable`; the answer carries `mode` (bound at placement, `pr` when the claim has no accepted dispatch whatever the source says now, null only on ledger contention) and `base_sha` (that merge base), an allow records it, and the merge executor merges only while the base head still equals it. Requires an orchestrator- or user-ROLE key (`exact_role: [:orchestrator, :user]`, so an agent key is 403'd); `LOOPCTL_ORCH_KEY` is sent when set, else `LOOPCTL_API_KEY`. 403 `custody_tier_required` without a human anchor, 404 for an unknown story, 422 when the story is not at `ci`. Required: `story_id` (refused locally if not a UUID) and `claim_epoch` (a non-negative integer, refused locally otherwise); optional `effect_proof`. |
525
+ | `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. `mode` picks the merge route: `pr` (the default when omitted) or `thread`, where the merge gate evaluates the story's latest recorded checkpoint instead of a pull request — which needs the source's runners at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; any other value, null included, is refused. The mode is bound to each implement dispatch when it is placed, so it decides only what stories placed afterwards get; a change is always allowed. Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, `mode`, and `target_epic_id` — **omitting the epic is not a neutral default**: with no epic every triaged report is escalated to a human and every record stays `pending_triage` until `intake_source_update` names one. Requires `LOOPCTL_USER_KEY`. |
526
+ | `intake_source_list` | List the tenant's intake sources (`GET /api/v1/intake/sources`): `id` (the webhook URL is `/api/v1/intake/github/<id>`), `project_id`, `repo_full_name`, `base_branch`, `mode` (`pr` or `thread`), `target_epic_id`, `revoked_at`, timestamps. **The webhook secret is not here and is not anywhere** — it is returned once by `intake_source_enroll`, so this is not how to recover one. Use it to find the source behind a 409 `no_intake_source` or `ambiguous_intake_source`, to check a repository's `base_branch` before placing work, and to identify a source left behind by an enrolment whose outcome was unknown. Optional: `include_revoked`. Requires `LOOPCTL_USER_KEY`; the only one of the four that does not also need a human-anchored tenant. |
527
+ | `intake_source_update` | **Set where a source's work lands** (`PATCH /api/v1/intake/sources/:id`): `target_epic_id`, the epic triaged stories are created in, `base_branch`, the branch every dispatch for this repository is cut from, and `mode` (`pr` or `thread`), whether the merge gate reads a pull request or the story's latest recorded thread checkpoint. Presence decides — a field you do not name is left exactly as it was, and naming none of them is 422 `nothing_to_update`. `mode` has no cleared state either, and a null or unknown value is refused locally; A change is always allowed and affects only stories placed afterwards: each implement dispatch records the mode, and the base branch, it was placed under, and the merge gate reads those. **Do not send `target_epic_id: null` to mean "not changing this":** an explicit null is the only way to CLEAR the epic, which returns the source to escalating every report instead of filing a story. `base_branch` has no cleared state and a null there is refused locally. This is how a source already pointed at the wrong trunk is corrected — `intake_source_enroll` takes `base_branch` itself, so a `main` repository no longer needs a second call — and the remedy for a source with no epic — every record from it stays `pending_triage` and is retried until one is named, then they promote on the next run with nothing lost. Revoked sources are 404, not a no-op: revoking clears the target so the epic can be deleted, and repointing one would put that block back. 422 when the epic is not in this source's project. Required: `source_id` (refused locally if it is not a UUID — the server answers the same 404 for malformed and unknown). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
528
528
  | `intake_source_revoke` | **Revoke an intake source** (`DELETE /api/v1/intake/sources/:id`). The verb is DELETE and the action is a revoke: `revoked_at` is stamped, the row is kept, and `intake_source_list` still returns it with `include_revoked`; nothing already received is discarded. Afterwards every delivery to that URL is refused 401 `invalid_signature` exactly as a wrong secret is, so the GitHub webhook does not know it has been cut off and should be deleted on the repository too. Idempotent — a second revoke answers 200 with the original `revoked_at`. It frees the repository's uniqueness slot (the index is partial on not-yet-revoked), so a source with a lost secret, a wrong project or a typo'd repository is corrected by revoking and enrolling again; and it clears `target_epic_id`, which is what makes that epic deletable and is the remedy an epic-delete refusal names. Required: `source_id` (refused locally if it is not a UUID). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
529
529
 
530
530
  ### Dispatch & Chain of Custody (v2) Tools
package/index.js CHANGED
@@ -8167,7 +8167,9 @@ const TOOLS = [
8167
8167
  "would find the first's work there: keep your prefix and append the suffix the error " +
8168
8168
  "names, or omit `branch`. 422 `branch_conflict` means a retry named a different branch " +
8169
8169
  "from the one this dispatch was already sent on — the branch is recorded at the first " +
8170
- "push and re-sent verbatim, because a session may be running on it right now. Neither " +
8170
+ "push and re-sent verbatim, because a session may be running on it right now; 422 " +
8171
+ "`base_branch_conflict` is the same for a different `base_branch`, which the merge gate " +
8172
+ "judges against. Neither " +
8171
8173
  "prefix refusal can be raised by a RETRY carrying a dispatch_id loopctl already " +
8172
8174
  "holds: its claim is already standing, so a declaration that changed under you only " +
8173
8175
  "steers the name and never strands the story.\n\n" +
@@ -8377,6 +8379,35 @@ const TOOLS = [
8377
8379
  "`refuse` (the story is escalated before this returns), `already_merged`, `head_moved` " +
8378
8380
  "(back to implementing), `unevaluated` (503 with Retry-After: a transient forge fault; " +
8379
8381
  "retry after the delay, nothing transitioned).\n\n" +
8382
+ "THREAD MODE — the story's current claim was PLACED under an intake source with " +
8383
+ "`mode: thread` (set with intake_source_update; the mode and the base branch are " +
8384
+ "bound to the dispatch at placement, so a later change reaches only later " +
8385
+ "placements). There is no pull request: the gate judges the latest checkpoint " +
8386
+ "recorded under the story's CURRENT claim, on the branch that claim's dispatch ran " +
8387
+ "on, and `pr_number` is null. The source's runners must be at runner " +
8388
+ "contract 1.20.0 or later and send checkpoint messages, or every story is refused " +
8389
+ "`no_checkpoint_recorded`; a story whose claim was released after an earlier one " +
8390
+ "recorded checkpoints is refused `claim_ended`, and a thread loopctl could not read " +
8391
+ "`thread_unreadable`. It adds refusals `empty_change` (the " +
8392
+ "checkpoint's tree equals the base's, or no file changed) and " +
8393
+ "`checkpoint_tree_mismatch` (the forge's tree is not the one the claimant recorded). " +
8394
+ "The branch is judged first: a branch missing from a readable repository " +
8395
+ "(`branch_missing`), one naming a commit nobody recorded (`branch_head_unrecorded`), " +
8396
+ "one naming an earlier checkpoint of the claim (`branch_head_regressed`), or a " +
8397
+ "checkpoint that is not the recorded head, means the head moved. While the claim is " +
8398
+ "LIVE that is `head_moved` — back to implementing: push and record the checkpoint. " +
8399
+ "When it is NOT live (reported, review requested, lease expired) nobody can record " +
8400
+ "the fix, so it is a refusal naming `claim_not_live`, and the story escalates. A " +
8401
+ "repository the token cannot read refuses `pull_request_unavailable`. The answer " +
8402
+ "carries `mode` (the one bound at placement, or `pr` when the claim has no accepted " +
8403
+ "dispatch, whatever the source says now; null only when the ledger read met contention), " +
8404
+ "`checkpoint_id`, `checkpoint_sha` and `base_sha`. The diff judged is the three-dot " +
8405
+ "diff against the merge base, so a base that moved on is not a refusal, and a " +
8406
+ "checkpoint the base already contains (its branch deleted or not) is " +
8407
+ "`already_merged` only under a recorded allow naming it, and otherwise refused " +
8408
+ "`checkpoint_on_base_without_allow`. An " +
8409
+ "allow records `base_sha` (that merge base); the merge executor merges only while " +
8410
+ "the base head still equals it.\n\n" +
8380
8411
  "REFUSALS. Needs an ORCHESTRATOR- or USER-role key: the action is `exact_role: " +
8381
8412
  "[:orchestrator, :user]`, so an agent key is 403'd. LOOPCTL_ORCH_KEY is sent when set, " +
8382
8413
  "else LOOPCTL_API_KEY. 403 `custody_tier_required` on a tenant without a human anchor, " +
@@ -8510,7 +8541,13 @@ const TOOLS = [
8510
8541
  "an unnamed branch on a `main` repository sends every dispatch to cut from a branch that " +
8511
8542
  "does not exist, and the failure arrives after the claim. There is no cleared state for " +
8512
8543
  "it (a dispatch must name one), so null or blank is refused rather than falling back to " +
8513
- "the default; intake_source_update changes it afterwards.",
8544
+ "the default; intake_source_update changes it afterwards.\n\n" +
8545
+ "`mode` picks the merge route: `pr` (the default) or `thread`, where the merge gate " +
8546
+ "evaluates the story's latest recorded checkpoint instead of a pull request. A value " +
8547
+ "other than those two, null included, is refused. " +
8548
+ "The mode is BOUND to each implement dispatch when it is placed: a change affects " +
8549
+ "only stories placed afterwards, and a story already placed keeps the mode its " +
8550
+ "dispatch recorded, so a change is always allowed.",
8514
8551
  inputSchema: {
8515
8552
  type: "object",
8516
8553
  properties: {
@@ -8547,6 +8584,18 @@ const TOOLS = [
8547
8584
  "here rather than silently taking the default. Changed later with " +
8548
8585
  "intake_source_update.",
8549
8586
  },
8587
+ mode: {
8588
+ type: "string",
8589
+ enum: ["pr", "thread"],
8590
+ description:
8591
+ "Optional. How this repository's changes reach its base branch. `pr` (the default " +
8592
+ "when omitted): the merge gate reads a pull request by number. `thread`: the merge " +
8593
+ "gate reads the story's latest RECORDED thread checkpoint and needs no pull " +
8594
+ "request (see merge_precondition). Its runners must be at runner contract 1.20.0 or " +
8595
+ "later and send checkpoint messages, or every story is refused " +
8596
+ "`no_checkpoint_recorded`. Not nullable. Changed later with " +
8597
+ "intake_source_update.",
8598
+ },
8550
8599
  secret_file: {
8551
8600
  type: "string",
8552
8601
  description:
@@ -8563,7 +8612,8 @@ const TOOLS = [
8563
8612
  description:
8564
8613
  "LIST THE TENANT'S GITHUB INTAKE SOURCES (GET /api/v1/intake/sources): per source its " +
8565
8614
  "`id` (the webhook URL is /api/v1/intake/github/<id>), `project_id`, `repo_full_name`, " +
8566
- "`base_branch`, `target_epic_id`, `revoked_at` and timestamps. Active sources only " +
8615
+ "`base_branch`, `mode` (`pr` or `thread`), `target_epic_id`, `revoked_at` and " +
8616
+ "timestamps. Active sources only " +
8567
8617
  "unless `include_revoked` is true.\n\n" +
8568
8618
  "THE WEBHOOK SECRET IS NOT HERE AND IS NOT ANYWHERE. It is returned once by " +
8569
8619
  "intake_source_enroll and the column is redacted on the schema, so this is not the way " +
@@ -8587,15 +8637,21 @@ const TOOLS = [
8587
8637
  name: "intake_source_update",
8588
8638
  description:
8589
8639
  "SET WHERE A SOURCE'S WORK LANDS (PATCH /api/v1/intake/sources/:id): `target_epic_id`, " +
8590
- "the epic triaged stories are created in, and `base_branch`, the branch every dispatch " +
8591
- "for this repository is cut FROM and carries.\n\n" +
8640
+ "the epic triaged stories are created in, `base_branch`, the branch every dispatch " +
8641
+ "for this repository is cut FROM and carries, and `mode`, the merge route (`pr` or " +
8642
+ "`thread` — a thread-mode source's merge gate reads the latest recorded checkpoint " +
8643
+ "instead of a pull request).\n\n" +
8592
8644
  "PRESENCE DECIDES, AND A FIELD YOU DO NOT NAME IS LEFT EXACTLY AS IT WAS. Naming " +
8593
- "neither is refused 422 `nothing_to_update` rather than being a silent no-op. DO NOT " +
8645
+ "none of them is refused 422 `nothing_to_update` rather than being a silent no-op. DO NOT " +
8594
8646
  "SEND `target_epic_id: null` TO MEAN 'I AM NOT CHANGING THIS' — an explicit null is the " +
8595
8647
  "only way to CLEAR the epic, and clearing it returns the source to escalating every " +
8596
8648
  "report to a human instead of filing a story. Leave the field out instead. " +
8597
- "`base_branch` has no cleared state at all (every dispatch must name a branch to cut " +
8598
- "from), so a null there is refused.\n\n" +
8649
+ "`base_branch` and `mode` have no cleared state at all, so a null there is refused. " +
8650
+ "The mode is BOUND to each implement dispatch when it is placed: a change affects " +
8651
+ "only stories placed afterwards, and a story already placed keeps the mode its " +
8652
+ "dispatch recorded, so a change is always allowed. `base_branch` is bound the same " +
8653
+ "way: a story already placed is merge-gated against the base branch its dispatch " +
8654
+ "was sent with.\n\n" +
8599
8655
  "THIS IS THE FIX FOR A SOURCE ALREADY POINTED AT THE WRONG TRUNK. intake_source_enroll " +
8600
8656
  "now takes `base_branch` itself, so a `main` repository is enrolled correctly in one " +
8601
8657
  "call; this is what corrects one that was not — a source enrolled before the parameter " +
@@ -8630,6 +8686,15 @@ const TOOLS = [
8630
8686
  "The branch dispatches for this repository are cut from, e.g. `main`. 1-255 " +
8631
8687
  "characters, not nullable. Omit to leave the current value alone.",
8632
8688
  },
8689
+ mode: {
8690
+ type: "string",
8691
+ enum: ["pr", "thread"],
8692
+ description:
8693
+ "`pr` or `thread`: whether the merge gate reads a pull request or the story's " +
8694
+ "latest recorded thread checkpoint. `thread` needs runners at contract 1.20.0 or " +
8695
+ "later sending checkpoint messages, or every story is refused " +
8696
+ "`no_checkpoint_recorded`. Not nullable. Omit to leave the current value alone.",
8697
+ },
8633
8698
  },
8634
8699
  required: ["source_id"],
8635
8700
  },
@@ -143,6 +143,24 @@ function epicRefusal(value, { clearable }) {
143
143
  return uuidRefusal(value, "target_epic_id");
144
144
  }
145
145
 
146
+ /**
147
+ * The two merge routes a source may take (US-45.4). `pr` is the default and what every source
148
+ * did before the field existed; `thread` makes the merge gate read the story's latest recorded
149
+ * checkpoint instead of a pull request. NOT nullable — the server answers 422 — so a null is
150
+ * refused here where the reason can be stated.
151
+ */
152
+ export const SOURCE_MODES = ["pr", "thread"];
153
+
154
+ export function modeRefusal(mode) {
155
+ if (SOURCE_MODES.includes(mode)) return null;
156
+
157
+ return refuse(
158
+ "`mode` must be `pr` or `thread`, or must be left out entirely (enrolment then takes " +
159
+ "`pr`, and an update keeps the current value). It cannot be null: every source reaches " +
160
+ "its base branch by one route or the other.",
161
+ );
162
+ }
163
+
146
164
  export function sourcePath(sourceId) {
147
165
  return `${SOURCES_PATH}/${encodeURIComponent(sourceId)}`;
148
166
  }
@@ -171,6 +189,7 @@ export function publicSource(source) {
171
189
  project_id: source.project_id,
172
190
  repo_full_name: source.repo_full_name,
173
191
  base_branch: source.base_branch,
192
+ mode: source.mode,
174
193
  target_epic_id: source.target_epic_id,
175
194
  revoked_at: source.revoked_at,
176
195
  inserted_at: source.inserted_at,
@@ -197,7 +216,7 @@ export function webhookUrl(baseUrl, webhookPath) {
197
216
  * shape. It never throws.
198
217
  */
199
218
  export async function enrollIntakeSource(
200
- { repo_full_name, project_id, target_epic_id, base_branch, secret_file } = {},
219
+ { repo_full_name, project_id, target_epic_id, base_branch, mode, secret_file } = {},
201
220
  { userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
202
221
  ) {
203
222
  if (!userKey) return refuse(MISSING_USER_KEY);
@@ -236,6 +255,14 @@ export async function enrollIntakeSource(
236
255
  }
237
256
  }
238
257
 
258
+ // OPTIONAL, read by PRESENCE like the branch: omitted, the source takes `pr`. Named, it
259
+ // must be one of the two modes; anything else (null included) is a 422 at the server and
260
+ // is refused here, before a secret file is reserved.
261
+ if (mode !== undefined) {
262
+ const badMode = modeRefusal(mode);
263
+ if (badMode) return badMode;
264
+ }
265
+
239
266
  if (typeof secret_file !== "string" || secret_file.trim() === "") {
240
267
  return refuse(
241
268
  "`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
@@ -285,6 +312,7 @@ export async function enrollIntakeSource(
285
312
  body.target_epic_id = target_epic_id;
286
313
  }
287
314
  if (base_branch !== undefined) body.base_branch = base_branch;
315
+ if (mode !== undefined) body.mode = mode;
288
316
 
289
317
  let result;
290
318
  try {
@@ -466,11 +494,17 @@ export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
466
494
  body.base_branch = args.base_branch;
467
495
  }
468
496
 
497
+ if (args.mode !== undefined) {
498
+ const badMode = modeRefusal(args.mode);
499
+ if (badMode) return badMode;
500
+ body.mode = args.mode;
501
+ }
502
+
469
503
  if (Object.keys(body).length === 0) {
470
504
  return refuse(
471
- "Nothing to update. Name at least one of `target_epic_id` (null clears it) or " +
472
- "`base_branch`. A body carrying neither is refused 422 `nothing_to_update`, because a " +
473
- "field you do not send is left exactly as it was.",
505
+ "Nothing to update. Name at least one of `target_epic_id` (null clears it), " +
506
+ "`base_branch` or `mode`. A body carrying none of them is refused 422 " +
507
+ "`nothing_to_update`, because a field you do not send is left exactly as it was.",
474
508
  );
475
509
  }
476
510
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.105.0",
3
+ "version": "2.106.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",