loopctl-mcp-server 2.102.0 → 2.104.1

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
@@ -68,7 +68,7 @@ Or if installed locally:
68
68
  |---|---|---|
69
69
  | `LOOPCTL_SERVER` | loopctl server URL | `https://loopctl.com` |
70
70
  | `LOOPCTL_API_KEY` | Global API key override: used for every tool except the exact-key ones below, and for the `exact_role: :orchestrator` custody verbs when `LOOPCTL_ORCH_KEY` is unset | -- |
71
- | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (report, review, import, and the `exact_role: :orchestrator` custody verbs `verify_story` / `reject_story` / `verify_all_in_epic` / `bulk_mark_complete` / `force_unclaim_story` — when it is set, it is the key those five send) | -- |
71
+ | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (report, review, import, and the `exact_role: :orchestrator` custody verbs `verify_story` / `reject_story` / `verify_all_in_epic` / `bulk_mark_complete` / `force_unclaim_story`, plus `merge_precondition` — when it is set, it is the key those six send) | -- |
72
72
  | `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
73
73
  | `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)), for destructive admin tools like `knowledge_bulk_publish`, and for the `runner_*` and `intake_source_*` tools plus `place_dispatch` and `resolve_escalation` in [Runner and delivery-loop tools](#runner-and-delivery-loop-tools). **Not for the whole of that section:** `story_stage` takes `LOOPCTL_AGENT_KEY`, and `force_unclaim_story` is `exact_role: :orchestrator`, where a user key is 403'd like any other non-member. | -- |
74
74
  | `LOOPCTL_STH_STATE_PATH` | Absolute path for the witness-protocol STH cache file (see [Witness protocol](#witness-protocol-sth)). Optional. | per-(server + key) file under the OS temp dir |
@@ -238,7 +238,8 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
238
238
  | Tool | Description |
239
239
  |---|---|
240
240
  | `list_stories` | List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. Pass `include_token_totals=true` for per-story token data. |
241
- | `list_ready_stories` | List stories that are ready to be worked on (contracted, dependencies met). |
241
+ | `list_ready_stories` | List stories that are ready to be contracted and worked on (`pending`, dependencies met, delivery stage not held). |
242
+ | `list_blocked_stories` | List stories with an unverified dependency (their own, or their epic's), whatever their own status, each with what blocks it (`GET /api/v1/stories/blocked`). The queued ones are what the dispatch driver skips and `place_dispatch` refuses `409 dependencies_not_met`. Optional `project_id` (422 if not a UUID), `page`, `page_size`. Agent key or higher. |
242
243
  | `get_story` | Get full details for a single story by ID. |
243
244
  | `create_story` | Create a single story inside an existing epic. Use instead of wrapping a story in a bulk import. Accepts either `epic_id` (UUID) or (`project_id` + `epic_number`). |
244
245
  | `update_story` | **Correct a filed story** (`PATCH /api/v1/stories/:id`) — its `title`, `description`, `acceptance_criteria`, `estimated_hours` or `metadata`, and nothing else. `number` is fixed at creation and the two status fields are custody state with their own gated endpoints. **Sending `metadata` REPLACES THE WHOLE MAP** — there is no merge on this path, so a partial send silently drops every key you left out and answers 200. Read the story first (`get_story`), then send the complete map; `acceptance_criteria` is replaced whole for the same reason. The one key it can no longer erase is `lifecycle_entered_at`, which is a COLUMN precisely because it used to live in metadata and one ordinary PATCH erased it, restoring the claim -> force-unclaim -> backfill-to-verified launder. A field cannot be set to `null` either: the controller drops every nil before the changeset, so `null` is refused here rather than answering 200 unchanged, and so is an `estimated_hours` no decimal parser accepts. Requires orchestrator role or higher (`LOOPCTL_ORCH_KEY`, or `LOOPCTL_API_KEY` of that role) on a human-anchored tenant. |
@@ -249,9 +250,9 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
249
250
 
250
251
  | Tool | Description |
251
252
  |---|---|
252
- | `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. |
253
- | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. |
254
- | `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`. Needs a loopctl server with claim leases. |
253
+ | `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. Refused 409 `story_held` when the story's delivery stage is `escalated`, `done` or `failed` — it is not available to agents even when it reads `pending`; an escalated story becomes available again only after `resolve_escalation` sends it to `queued`. |
254
+ | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. Refused 409 `story_held` when the story's delivery stage is `escalated`, `done` or `failed` — even when it reads `pending` or `contracted`; an escalated story is claimable again only after `resolve_escalation` sends it to `queued`, a done or failed one never is. |
255
+ | `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. A driver-placed claim — one a placement took for a runner dispatch — is capped at its dispatch deadline (`claim_lease_cap`: placed_at + `wall_clock_seconds` + `DISPATCH_LEASE_GRACE_SECONDS` at the claim, sent to the runner as `deadline_at`; moved forward to a resume's time or the runner's acceptance + `wall_clock_seconds` + the grace, only while the claim is live; never earlier than a `deadline_at` already sent), and its `claimed_until` already IS that cap: renewing it writes nothing and returns the claim as it stands, never moving `claimed_until` past it. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`, 409 `lease_cap_reached` (a driver-placed claim already at its cap: nothing is renewed, stop working it). Needs a loopctl server with claim leases. |
255
256
  | `escalate_story` | Park a story you hold for a HUMAN and stop (`POST /stories/:id/escalate`, agent key). This is what an unattended session calls INSTEAD of asking a question — a headless run has no way to ask, so escalating is the affordance. Use it when the request contradicts an existing story, KB decision or documented behaviour, when it inverts or removes behaviour a previous story deliberately added, when it is a workflow change rather than a defect fix, or when it needs a business call — not for an ordinary blocker. ONE-WAY from your side: only a human moves the story off `escalated`, so on success STOP and report. Required: `story_id`, `claim_epoch`, `reason` (verbatim, treated as untrusted data, length capped by the server in codepoints — a 400 names the bound); optional `payload` object. Idempotent under the same epoch. Refusals pass through: 409 `stale_claim_epoch`, 409 `not_claimant`, 404 `unknown_story_stage`, 400 for a malformed epoch or reason. An orchestrator or user key is 403'd on purpose. |
256
257
  | `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
257
258
  | `request_review` | Agent signals implementation is complete and ready for review. |
@@ -268,7 +269,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
268
269
  | Tool | Description |
269
270
  |---|---|
270
271
  | `verify_story` | Orchestrator verifies a reported_done story. Transitions reported_done -> verified. Under the signed profile, pass the `claim` from `custody_sign_claim`. |
271
- | `reject_story` | Orchestrator rejects a story with a reason. |
272
+ | `reject_story` | Orchestrator rejects a story with a reason. The reject releases the claim, and a delivery story may be re-contracted or escalated in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain entry was refused) means the WHOLE call rolled back: the story is unchanged, and a re-run meets the same server-side condition until an operator acts. |
272
273
 
273
274
  ### Bulk Tools (orchestrator key)
274
275
 
@@ -503,6 +504,8 @@ The generated-tool count per tenant is bounded by the per-tenant entity cap.
503
504
 
504
505
  Enroll the dev machines that run the agent delivery loop, see which are connected (issue #809), bind the repositories whose issues feed it, place work on them and recover a story a runner would not take. The `runner_*` and `intake_source_*` tools require `LOOPCTL_USER_KEY`, and their writes additionally require a human-anchored tenant. The delivery-loop verbs below do NOT share one key — each row names the key its endpoint's gate demands, and a higher-privileged key does not substitute for an `exact_role` one.
505
506
 
507
+ The order to wire it up, the stage machine these tools move a story through, and every operator setting the loop reads are in loopctl's [`docs/agent-delivery-loop.md`](https://github.com/mkreyman/loopctl/blob/master/docs/agent-delivery-loop.md). In short: `runner_enroll`, `intake_source_enroll` plus the GitHub webhook, confirm the machine in `runner_pool`, `place_dispatch` one story and watch it with `story_stage`, then turn on the unattended driver server-side.
508
+
506
509
  **A loop with no intake source has no input.** `intake_source_enroll` is the step that points a GitHub repository at loopctl, and the same row is where `place_dispatch` reads `repo` and `base_branch` from — its 409 `no_intake_source` names exactly this. The webhook secret it returns is written to a file and never appears in a tool result; see that row.
507
510
 
508
511
  | Tool | Description |
@@ -510,11 +513,12 @@ Enroll the dev machines that run the agent delivery loop, see which are connecte
510
513
  | `runner_enroll` | Enroll this machine as a runner (`POST /api/v1/runners`). Required: `name` (the machine name the runner declares when it joins) and `token_file` (absolute, or starting with `~/`). Optional: `max_sessions` (1..64, default 2) — the CEILING on how many dispatches loopctl will ever keep in flight on this machine at once, and the value it starts at. Since runner contract 1.13.0 every join re-applies the machine's own declared `max_sessions` bounded by this one, so a machine may take itself down below its grant and cannot raise itself above it: to carry FEWER sessions change `control.max_sessions` in the runner's own config and reconnect, to carry MORE `runner_revoke` it first and then enrol it again (nothing widens the ceiling in place, and enrolling the same name while its runner is active is a 422; the revoke invalidates the credential, so the new token file must reach the machine and the runner be restarted). The tenant's total across its runners is capped separately by the server. The credential is written to `token_file` with mode 0600 and is **never returned**: the result is only `{ runner: {id, name, inserted_at}, token_file }`, because a tool result lands in the transcript and the token lets its holder join as that machine. The file is created exclusively, so an existing path (or a symlink there) is refused before anything is enrolled; missing parent directories are created 0700. If the token cannot be written after enrollment, the runner is revoked before the error returns. Only a 4xx is treated as a refusal: any other failure (timeout, 5xx, a 2xx that did not parse) may have enrolled the runner, so its response body is never echoed, the runner is revoked when the response proves its id, and otherwise the error points at `runner_list` and `runner_revoke`. 422 (name malformed or taken) and 403 (`custody_tier_required`, `api_key_mint_forbidden`) pass through with their code. |
511
514
  | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
512
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`. |
513
- | `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`. Presence converges only within a cluster. |
514
- | `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. 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 `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
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`. |
515
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`. |
516
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`. |
517
- | `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`. **It frees the STAGE; it does not make the story placeable.** `place_dispatch` wants `contracted` + `queued` (see its own row in this table), and the release leaves the story at `pending`, whose only transition is `pending -> contracted` — so running `place_dispatch` next answers the identical 409 `invalid_transition`. The remedy is three calls, in this order: `force_unclaim_story`, then `contract_story`, then `place_dispatch`. 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`. Safe to run twice: on an already-pending story the stage row is written only if it is stranded behind the story's claim epoch or still in flight. No request body. Required: `story_id` (refused locally if it is not a UUID). |
520
+ | `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). |
521
+ | `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`. |
518
522
  | `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`. |
519
523
  | `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. |
520
524
  | `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. |
package/index.js CHANGED
@@ -49,6 +49,7 @@ import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./l
49
49
  import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
50
50
  import {
51
51
  forceUnclaimStory as forceUnclaimStoryRequest,
52
+ mergePrecondition as mergePreconditionRequest,
52
53
  placeDispatch as placeDispatchRequest,
53
54
  resolveEscalation as resolveEscalationRequest,
54
55
  storyStage as storyStageRequest,
@@ -1059,6 +1060,18 @@ async function listReadyStories({ project_id, page, page_size }) {
1059
1060
  return toContentCompact(result);
1060
1061
  }
1061
1062
 
1063
+ async function listBlockedStories({ project_id, page, page_size }) {
1064
+ const params = new URLSearchParams();
1065
+ if (project_id != null) params.set("project_id", project_id);
1066
+ if (page != null) params.set("page", String(page));
1067
+ if (page_size != null)
1068
+ params.set("page_size", String(Math.min(page_size, SERVER_MAX_STORY_PAGE_SIZE)));
1069
+
1070
+ const qs = params.toString();
1071
+ const result = await apiCall("GET", `/api/v1/stories/blocked${qs ? `?${qs}` : ""}`);
1072
+ return toContentCompact(result);
1073
+ }
1074
+
1062
1075
  async function getStory({ story_id }) {
1063
1076
  const result = await apiCall("GET", `/api/v1/stories/${story_id}`);
1064
1077
  return toContent(result);
@@ -3295,6 +3308,19 @@ async function forceUnclaimStory(args) {
3295
3308
  );
3296
3309
  }
3297
3310
 
3311
+ // Epic 44, US-44.1: the merge gate's second run. Same key selection as force-unclaim — the
3312
+ // action is `exact_role: [:orchestrator, :user]` and LOOPCTL_ORCH_KEY is pinned when set.
3313
+ async function mergePreconditionTool(args) {
3314
+ const orch = orchestratorKeyArgs();
3315
+
3316
+ return toContent(
3317
+ await mergePreconditionRequest(args, {
3318
+ orchKey: orch.resolved,
3319
+ apiCall: (method, path, body) => apiCall(method, path, body, orch.override, orch.options),
3320
+ }),
3321
+ );
3322
+ }
3323
+
3298
3324
  // #803/#846: GitHub intake sources — the row that makes a webhook possible, and the binding
3299
3325
  // `place_dispatch` reads `repo` and `base_branch` from. The logic, including how the enrol
3300
3326
  // path keeps the webhook secret out of the tool result, lives in lib/intake-sources.js.
@@ -4290,6 +4316,32 @@ const TOOLS = [
4290
4316
  required: ["project_id"],
4291
4317
  },
4292
4318
  },
4319
+ {
4320
+ name: "list_blocked_stories",
4321
+ description:
4322
+ "List stories with an unverified dependency — a story they depend on, or a story in an " +
4323
+ "epic their epic depends on — each with the blocking dependencies. It is by dependency " +
4324
+ "alone, WHATEVER the story's own status: an in-flight or finished story whose " +
4325
+ "prerequisite was later rejected is listed too. The rows carry no delivery stage: the " +
4326
+ "ones the dispatch driver skips are those whose story_stage is queued (check with " +
4327
+ "story_stage), and place_dispatch refuses them 409 dependencies_not_met. " +
4328
+ "Compact; paginated (page/page_size) with total_count. Refuses 422 when project_id is " +
4329
+ "not a UUID. Key: agent role or higher.",
4330
+ inputSchema: {
4331
+ type: "object",
4332
+ properties: {
4333
+ project_id: {
4334
+ type: "string",
4335
+ description: "Optional: the UUID of a project to limit the list to.",
4336
+ },
4337
+ page: { type: "integer", description: "Page number (default 1)." },
4338
+ page_size: {
4339
+ type: "integer",
4340
+ description: "Stories per page (default 100, max 500).",
4341
+ },
4342
+ },
4343
+ },
4344
+ },
4293
4345
  {
4294
4346
  name: "get_story",
4295
4347
  description: "Get full details for a single story by ID.",
@@ -4311,7 +4363,11 @@ const TOOLS = [
4311
4363
  description:
4312
4364
  "Agent acknowledges a story's acceptance criteria to claim the contract. " +
4313
4365
  "Transitions the story from pending to contracted. " +
4314
- "story_title and ac_count must match the actual story to prevent silent misclaims.",
4366
+ "story_title and ac_count must match the actual story to prevent silent misclaims. " +
4367
+ "Refused 409 story_held when the story's delivery stage is escalated, done or failed: " +
4368
+ "it is not available to agents even when it reads pending — move on. An escalated " +
4369
+ "story becomes available again only after resolve_escalation sends it to queued; a " +
4370
+ "done or failed one never does.",
4315
4371
  inputSchema: {
4316
4372
  type: "object",
4317
4373
  properties: {
@@ -4338,7 +4394,12 @@ const TOOLS = [
4338
4394
  "Transitions contracted -> assigned. Uses the AGENT key. On a loopctl with claim leases " +
4339
4395
  "the result leads with the claim's claim_epoch and claimed_until: keep the epoch, and " +
4340
4396
  "renew with renew_story_claim before claimed_until (default lease 24 hours) or the story " +
4341
- "is released back to pending under you.",
4397
+ "is released back to pending under you. Refused 409 story_held when the story's " +
4398
+ "delivery stage is escalated, done or failed: it is not yours to claim even when it " +
4399
+ "reads pending or contracted — move on. An escalated story is claimable again only " +
4400
+ "after resolve_escalation sends it to queued; a done or failed one never is. Refused " +
4401
+ "409 dependencies_not_met when a story it depends on, or one in an epic its epic " +
4402
+ "depends on, is not verified: list_blocked_stories names them.",
4342
4403
  inputSchema: {
4343
4404
  type: "object",
4344
4405
  properties: {
@@ -4354,12 +4415,22 @@ const TOOLS = [
4354
4415
  name: "renew_story_claim",
4355
4416
  description:
4356
4417
  "Renew your claim's lease (POST /api/v1/stories/:id/renew-claim): claimed_until becomes " +
4357
- "now plus the lease length, measured from NOW. Call it well inside the lease on any story " +
4358
- "you hold longer than it. Uses the AGENT key, the same key as claim_story. Refusals pass " +
4418
+ "now plus the lease length (default 24 hours), measured from NOW. Call it well inside the " +
4419
+ "lease on any story you hold longer than it. A DRIVER-PLACED claim (one a placement took " +
4420
+ "for a runner dispatch) is CAPPED AT ITS DISPATCH DEADLINE (claim_lease_cap: " +
4421
+ "placed_at + wall_clock_seconds + DISPATCH_LEASE_GRACE_SECONDS at the claim, which is " +
4422
+ "the dispatch's deadline_at; moved forward to a resume's time or the runner's " +
4423
+ "acceptance + wall_clock_seconds + the grace, only while the claim is live; never " +
4424
+ "earlier than a deadline_at already sent), and its claimed_until already IS that cap: " +
4425
+ "renewing it writes nothing and returns the claim as it stands, so renewing " +
4426
+ "never moves claimed_until past that instant. " +
4427
+ "Uses the AGENT key, the same key as claim_story. Refusals pass " +
4359
4428
  "through: 400 claim_epoch missing or not a non-negative integer; 422 not_claimed (the " +
4360
4429
  "story is not assigned or implementing); 409 stale_claim_epoch (the claim has ENDED: it " +
4361
4430
  "expired and was reclaimed, released, or claimed again, so stop working it); 409 " +
4362
- "not_claimant (your key's agent is not the story's assigned agent).",
4431
+ "not_claimant (your key's agent is not the story's assigned agent); " +
4432
+ "409 lease_cap_reached (a driver-placed claim already at its cap: nothing is renewed " +
4433
+ "and the claim ends there, so stop working it).",
4363
4434
  inputSchema: {
4364
4435
  type: "object",
4365
4436
  properties: {
@@ -4575,7 +4646,11 @@ const TOOLS = [
4575
4646
  name: "reject_story",
4576
4647
  description:
4577
4648
  "Orchestrator rejects a story with a reason. " +
4578
- "Creates a verification_result with result=fail. Uses the ORCH key.",
4649
+ "Creates a verification_result with result=fail. Uses the ORCH key. " +
4650
+ "The reject releases the claim, and a delivery story may be re-contracted or escalated " +
4651
+ "in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain " +
4652
+ "entry was refused) means the WHOLE call rolled back: the story is unchanged, and a " +
4653
+ "re-run meets the same server-side condition until an operator acts.",
4579
4654
  inputSchema: {
4580
4655
  type: "object",
4581
4656
  properties: {
@@ -8002,6 +8077,11 @@ const TOOLS = [
8002
8077
  "no_conforming_branch, and when a machine refuses dispatches with branch_not_allowed — a " +
8003
8078
  "runner that ENFORCES a prefix and declares none shows [] here, which is that " +
8004
8079
  "misconfiguration made visible. Per-CONNECTION, like kinds. " +
8080
+ "usage_exhausted_until (runner contract 1.17.0) is until when the machine's SUBSCRIPTION " +
8081
+ "is exhausted, or null: while it is in the future nothing is placed on the machine and " +
8082
+ "place_dispatch refuses 409 runner_exhausted. It is the EFFECTIVE value — the latest of " +
8083
+ "the machine's own and every machine's sharing its account — so a machine that never " +
8084
+ "reported anything can show one; capacity returns at that instant on its own. " +
8005
8085
  "live_sockets " +
8006
8086
  "above 1 means more than one process holds that runner's credential. A killed runner " +
8007
8087
  "disappears once its socket closes. Presence converges only " +
@@ -8017,7 +8097,8 @@ const TOOLS = [
8017
8097
  "push the work (POST /api/v1/runners/:runner_id/dispatches). This is the control-side " +
8018
8098
  "trigger of the agent delivery loop — the verb that turns a story the loop has decided " +
8019
8099
  "to build into a session running on a machine. Get `runner_id` and its free slots from " +
8020
- "runner_pool; the story must be `contracted` with its delivery stage at `queued` " +
8100
+ "runner_pool; the story must be `pending` or `contracted` (a pending one is contracted " +
8101
+ "by the placement), its dependencies met, with its delivery stage at `queued` " +
8021
8102
  "(story_stage shows where it is).\n\n" +
8022
8103
  "The STORY OBJECT is not a parameter: loopctl builds it from its own rows and refuses a " +
8023
8104
  "caller-supplied one, because a control plane able to hand a runner prose is able to " +
@@ -8036,6 +8117,11 @@ const TOOLS = [
8036
8117
  "choosing a machine. It refuses a NEW placement only: retrying with a dispatch_id " +
8037
8118
  "loopctl already holds still re-sends that dispatch, because its claim is already " +
8038
8119
  "standing and a draining machine is asked to finish what it holds, not to take more.\n\n" +
8120
+ "409 `runner_exhausted` means the machine's subscription (its own, or one it shares an " +
8121
+ "account with) is exhausted, so every session placed there would end usage_exhausted. " +
8122
+ "NOTHING WAS CLAIMED; the body carries `usage_exhausted_until`, when it clears on its " +
8123
+ "own. Place on another runner — runner_pool shows `usage_exhausted_until` per machine. " +
8124
+ "Like `runner_declines_work` it refuses a NEW placement only.\n\n" +
8039
8125
  "409 `no_conforming_branch` means the machine declares `branch_prefixes` (runner " +
8040
8126
  "contract 1.14.0) and none of them can produce a valid branch name carrying the story " +
8041
8127
  "number and id fragment that keeps two stories off one branch. NOTHING WAS CLAIMED. " +
@@ -8054,7 +8140,15 @@ const TOOLS = [
8054
8140
  "push and re-sent verbatim, because a session may be running on it right now. Neither " +
8055
8141
  "prefix refusal can be raised by a RETRY carrying a dispatch_id loopctl already " +
8056
8142
  "holds: its claim is already standing, so a declaration that changed under you only " +
8057
- "steers the name and never strands the story.",
8143
+ "steers the name and never strands the story.\n\n" +
8144
+ "A RETRY also moves the claim's deadline: the re-sent dispatch carries deadline_at = now + " +
8145
+ "its wall_clock_seconds + DISPATCH_LEASE_GRACE_SECONDS (never earlier than the one already " +
8146
+ "sent), so a late or a longer retry gets its whole clock. 409 `dispatch_claim_ended` means " +
8147
+ "the claim that dispatch_id was placed under has ENDED — its lease ran out, or the story " +
8148
+ "left assigned/implementing: nothing was pushed or written and the claim is not revived. " +
8149
+ "Place the story again with a NEW dispatch_id once it is placeable. 409 " +
8150
+ "`dependencies_not_met` means a story it depends on, or one in an epic its epic depends on, " +
8151
+ "is not verified: nothing was minted, claimed or pushed; place it once they are.",
8058
8152
  inputSchema: {
8059
8153
  type: "object",
8060
8154
  properties: {
@@ -8165,6 +8259,46 @@ const TOOLS = [
8165
8259
  required: ["story_id", "to"],
8166
8260
  },
8167
8261
  },
8262
+ {
8263
+ name: "merge_precondition",
8264
+ description:
8265
+ "RUN THE MERGE GATE over a story's real pull request " +
8266
+ "(POST /api/v1/stories/:id/merge-precondition). Both delivery gates run again over the " +
8267
+ "diff that exists, plus custody (`verified_status: verified` by a separate lineage), the " +
8268
+ "12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy " +
8269
+ "exclusion. The story must be at stage `ci`.\n\n" +
8270
+ "GATE A READS WHAT TRIAGE PERSISTED, NEVER YOU. Its input is the lens verdicts recorded " +
8271
+ "with the story's most recent triage, or a human's re-queue of a Gate A escalation; this " +
8272
+ "tool sends no trio. `gate_a_inputs` on the answer says which (`persisted_triage`, " +
8273
+ "`human_resolution`, or `missing` — which refuses with `gate_a_inputs_missing`).\n\n" +
8274
+ "DECISIONS: `allow` (recorded against the head, the only thing that licenses a merge), " +
8275
+ "`refuse` (the story is escalated before this returns), `already_merged`, `head_moved` " +
8276
+ "(back to implementing), `unevaluated` (503 with Retry-After: a transient forge fault; " +
8277
+ "retry after the delay, nothing transitioned).\n\n" +
8278
+ "REFUSALS. Needs an ORCHESTRATOR- or USER-role key: the action is `exact_role: " +
8279
+ "[:orchestrator, :user]`, so an agent key is 403'd. LOOPCTL_ORCH_KEY is sent when set, " +
8280
+ "else LOOPCTL_API_KEY. 403 `custody_tier_required` on a tenant without a human anchor, " +
8281
+ "404 for an unknown story, 422 when `claim_epoch` is missing or the story is not at `ci` " +
8282
+ "or has no stage row. A `story_id` that is not a UUID or a bad `claim_epoch` is refused " +
8283
+ "here, before any call.",
8284
+ inputSchema: {
8285
+ type: "object",
8286
+ properties: {
8287
+ story_id: { type: "string", description: "The story UUID, at stage `ci`." },
8288
+ claim_epoch: {
8289
+ type: "integer",
8290
+ description: "The claim epoch you act under; it fences the escalation a refusal writes.",
8291
+ },
8292
+ effect_proof: {
8293
+ type: "object",
8294
+ description:
8295
+ "Optional effect proof for a change touching an effect path. Judged and recorded; " +
8296
+ "it cannot turn a `prove_effect` outcome into an allow.",
8297
+ },
8298
+ },
8299
+ required: ["story_id", "claim_epoch"],
8300
+ },
8301
+ },
8168
8302
  {
8169
8303
  name: "force_unclaim_story",
8170
8304
  description:
@@ -8172,12 +8306,13 @@ const TOOLS = [
8172
8306
  "Two things happen: the story resets to `agent_status: pending` with " +
8173
8307
  "`assigned_agent_id` cleared, AND — in the same transaction — its delivery stage row " +
8174
8308
  "follows the release back to `queued`.\n\n" +
8175
- "IT FREES THE STAGE; IT DOES NOT MAKE THE STORY PLACEABLE. place_dispatch wants " +
8176
- "`agent_status: contracted` AND stage `queued` (`Placement.claimable/2`), and the " +
8177
- "release leaves the story at `pending`, whose only transition is `pending -> " +
8178
- "contracted`. Run place_dispatch straight after this one and you get back the IDENTICAL " +
8179
- "409 `invalid_transition`. THE REMEDY IS THREE CALLS, in this order: " +
8180
- "force_unclaim_story, then contract_story, then place_dispatch.\n\n" +
8309
+ "A DELIVERY STORY THEN GOES TO `escalated`, NOT BACK TO THE QUEUE (loopctl US-44.4). An " +
8310
+ "operator taking a story back is a human decision, so when the release leaves the stage " +
8311
+ "row at `queued` loopctl escalates it over `operator_released` in the same transaction — " +
8312
+ "it is not left in the queue behind the human's back. It spends no attempt " +
8313
+ "against the retry ceiling. To put it back to work, call resolve_escalation with " +
8314
+ "`to: queued`: that releases (a no-op now) AND re-contracts, so the story is placeable " +
8315
+ "again. A story with no delivery stage row is simply left `pending`, as before.\n\n" +
8181
8316
  "WHEN TO REACH FOR IT. A story sitting at `claimed` with nobody on it is the residue of " +
8182
8317
  "a compensation that did not complete — it is NOT what a refused dispatch normally " +
8183
8318
  "leaves. Placement answers a runner's refusal INLINE by releasing the claim itself " +
@@ -8198,11 +8333,18 @@ const TOOLS = [
8198
8333
  "A REGISTERED AGENT — an unlinked one is refused 400 naming that — and the tenant must be " +
8199
8334
  "human-anchored (403 `custody_tier_required` otherwise). 404 for an unknown story, 429 " +
8200
8335
  "when rate limited. A `story_id` that is not a UUID is refused here, before any call.\n\n" +
8201
- "It does NOT touch `verified_status`, and it is safe to run twice: on an already-pending " +
8202
- "story the stage row is written only when it needs to be — a row STRANDED behind the " +
8203
- "story's claim epoch is rebound to it (the remedy for a row an older release left " +
8204
- "behind) and an in-flight row is requeued, while a row already at that epoch is left " +
8205
- "exactly as it is. It takes no request body.",
8336
+ "It does NOT touch `verified_status`. RUN AGAIN on an already-pending story, it is still " +
8337
+ "an operator's release and does what one does: a row STRANDED behind the story's claim " +
8338
+ "epoch is rebound to it (the remedy for a row an older release left behind), an " +
8339
+ "in-flight row is requeued, and a row that is then at `queued` — including one that was " +
8340
+ "already sitting there — is ESCALATED over `operator_released`, like the first run. A " +
8341
+ "row already escalated, or anywhere else at that epoch, is left exactly as it is. EVERY " +
8342
+ "500 means the whole release rolled back and the story is still claimed: " +
8343
+ "`audit_chain_append_failed` (the escalation's chain entry was refused) is a " +
8344
+ "server-side condition a re-run meets again until an operator acts; " +
8345
+ "`force_unclaim_failed` (the " +
8346
+ "server log names the step) is remedied by calling it again. A 422 means the release " +
8347
+ "write itself was rejected. It takes no request body.",
8206
8348
  inputSchema: {
8207
8349
  type: "object",
8208
8350
  properties: {
@@ -9153,6 +9295,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9153
9295
  case "list_ready_stories":
9154
9296
  return await listReadyStories(args);
9155
9297
 
9298
+ case "list_blocked_stories":
9299
+ return await listBlockedStories(args);
9300
+
9156
9301
  case "get_story":
9157
9302
  return await getStory(args);
9158
9303
 
@@ -9464,6 +9609,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9464
9609
  case "force_unclaim_story":
9465
9610
  return await forceUnclaimStory(args);
9466
9611
 
9612
+ case "merge_precondition":
9613
+ return await mergePreconditionTool(args);
9614
+
9467
9615
  case "intake_source_enroll":
9468
9616
  return await intakeSourceEnroll(args);
9469
9617
 
@@ -2,7 +2,9 @@
2
2
  * Story claim leases (loopctl #803/#810, MCP side under #809).
3
3
  *
4
4
  * A claim now carries a LEASE (`claimed_until`, default 24h, STORY_CLAIM_LEASE_SECONDS)
5
- * and a FENCE (`claim_epoch`, bumped by every claim and every release). A claim that is not
5
+ * and a FENCE (`claim_epoch`, bumped by every claim and every release). A claim a placement
6
+ * took for a runner dispatch also carries `claim_lease_cap` (#879): its dispatch deadline,
7
+ * which no renewal moves the lease past. A claim that is not
6
8
  * renewed before `claimed_until` is released back to `pending` under the agent still
7
9
  * working it. `renew_story_claim` is the MCP path to `POST /stories/:id/renew-claim`, and
8
10
  * `claimLeaseNotice` puts the epoch and the deadline at the top of a `claim_story` result so
@@ -13,8 +15,9 @@
13
15
  * travels on is testable behaviour rather than a source pattern.
14
16
  *
15
17
  * Nothing is validated client-side beyond `story_id`: the server's 400 (missing or
16
- * non-integer epoch), 422 `not_claimed`, 409 `stale_claim_epoch` and 409 `not_claimant`
17
- * pass through unchanged, because each one tells the agent something different to do.
18
+ * non-integer epoch), 422 `not_claimed`, 409 `stale_claim_epoch`, 409 `not_claimant` and
19
+ * 409 `lease_cap_reached` pass through unchanged, because each one tells the agent
20
+ * something different to do.
18
21
  */
19
22
 
20
23
  export function renewClaimPath(storyId) {
@@ -45,8 +48,14 @@ export function claimLeaseNotice(result) {
45
48
  "story is released back to pending under you."
46
49
  : "no lease (claimed_until is null), so it is never released automatically.";
47
50
 
51
+ // #879: a driver-placed claim is capped at its dispatch deadline, and no renewal passes it.
52
+ const cap = story.claim_lease_cap
53
+ ? ` This claim is CAPPED at its dispatch deadline ${story.claim_lease_cap}: renewing never ` +
54
+ "moves claimed_until past it."
55
+ : "";
56
+
48
57
  return (
49
- `CLAIM LEASE: claim_epoch ${story.claim_epoch}, ${lease} Keep claim_epoch; ` +
58
+ `CLAIM LEASE: claim_epoch ${story.claim_epoch}, ${lease}${cap} Keep claim_epoch; ` +
50
59
  "renew_story_claim requires it. A 409 stale_claim_epoch means the claim has ended: stop working it."
51
60
  );
52
61
  }
@@ -60,7 +60,7 @@ function refuse(body) {
60
60
  *
61
61
  * NOT on `place_dispatch`'s `dispatch_id`, which is an id and is forwarded unchecked. That is a
62
62
  * decision and not an omission: it travels in the request BODY, and `Placement.place/4` casts it
63
- * with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:786-791`) as the second clause of its
63
+ * with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:1631-1636`) as the second clause of its
64
64
  * `with` — before the caller is resolved, before anything is minted and before the claim — so a
65
65
  * malformed one answers `422 invalid_payload` with `details: ["dispatch_id: must be a UUID"]`
66
66
  * (`lib/loopctl_web/controllers/dispatch_placement_controller.ex:321-326`). That names the
@@ -87,7 +87,7 @@ function refuse(body) {
87
87
  * go find the right story. The check here can tell them apart, and does it without a round trip.
88
88
  *
89
89
  * SCOPE OF THAT TRACE. The path read end to end is force-unclaim:
90
- * `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:3467-3470`),
90
+ * `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:3902-3912`),
91
91
  * which puts `story_id` straight into a `where` against a `:binary_id` column with no cast. The
92
92
  * other verbs are NOT claimed to reach that same code. They do not need to: a shape check is
93
93
  * worth its line on any argument that is interpolated into a URL path and must be a UUID,
@@ -242,33 +242,30 @@ export async function resolveEscalation({ story_id, to, reason } = {}, { userKey
242
242
  * `POST /api/v1/stories/:id/force-unclaim`: take a story back off the agent holding it.
243
243
  *
244
244
  * TWO things happen. `force_unclaim_story/3` resets `agent_status` to `pending` and clears
245
- * `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:1438`); then, in the SAME
245
+ * `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:1607`); then, in the SAME
246
246
  * transaction, `Stages.follow_release/5` makes the delivery stage row follow the release —
247
247
  * from any stage a claim holds (`claimed`, `worktree`, `implementing`, `reviewing`, `pr_open`,
248
248
  * `ci`) back to `queued`, rebound to the new claim epoch.
249
249
  *
250
- * ## IT FREES THE STAGE. IT DOES NOT MAKE THE STORY PLACEABLE.
250
+ * ## A DELIVERY STORY GOES TO `escalated`, NOT BACK TO THE QUEUE
251
251
  *
252
- * This comment used to say the second half was "what makes the story PLACEABLE again", and
253
- * that is false. `Placement.claimable/2` (`placement.ex:482-490`) wants `agent_status ==
254
- * :contracted` AND stage `queued`; the release leaves the story at `:pending`, and
255
- * `@valid_transitions` (`progress.ex:3480`) has `pending: :contracted` and nothing else — so
256
- * `place_dispatch` run straight afterwards answers the IDENTICAL 409 `invalid_transition`.
252
+ * An operator taking a story back is a human decision (loopctl US-44.4, #877), so when the
253
+ * release leaves the delivery stage row at `queued`, `Stages.follow_release/5` escalates it over
254
+ * the control-only `{queued, escalated, operator_released}` edge in the same transaction. It
255
+ * spends no attempt against the retry ceiling. The story is never left at `queued` + `pending`,
256
+ * which `Placement.claimable/2` refuses and nothing re-contracted.
257
257
  *
258
- * The remedy is THREE CALLS, in this order, and the tool descriptions say so:
259
- * `force_unclaim_story`, then `contract_story`, then `place_dispatch`. (An earlier draft called
260
- * it "two steps" and then named three, which is worse than saying nothing: an operator counting
261
- * steps runs two of the three and takes the 409 this copy exists to prevent.)
262
- * (`resolve_escalation` is the one that does both for you: `Escalations.prepare_story/5`
263
- * releases AND re-contracts on the `queued` route, `escalations.ex:335-342`.)
258
+ * To put it back to work: `resolve_escalation` with `to: queued`, which releases (a no-op by
259
+ * then) AND re-contracts (`Escalations.prepare_story/6`). A story with no stage row is simply
260
+ * left `pending`, exactly as before.
264
261
  *
265
262
  * ## WHEN A STORY IS ACTUALLY PARKED
266
263
  *
267
264
  * Not on an ordinary refusal — that path self-heals. `Placement.place/4` answers a
268
- * `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:562`, `:706-711`),
265
+ * `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:917`, `:1211`),
269
266
  * which releases the claim through this same function, unrecords the session dispatch and
270
267
  * revokes it. If that release itself fails, the claim lease is a further backstop:
271
- * `Progress.reclaim_expired_claim/3` (`progress.ex:1612`) releases over `:runner_lost` and
268
+ * `Progress.reclaim_expired_claim/3` (`progress.ex:1805`) releases over `:runner_lost` and
272
269
  * requeues the stage, swept by `ReclaimExpiredClaimsWorker` every five minutes once
273
270
  * `claimed_until` has passed.
274
271
  *
@@ -304,3 +301,32 @@ export async function forceUnclaimStory({ story_id } = {}, { orchKey, apiCall }
304
301
 
305
302
  return apiCall("POST", forceUnclaimPath(story_id), null);
306
303
  }
304
+
305
+ export function mergePreconditionPath(storyId) {
306
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/merge-precondition`;
307
+ }
308
+
309
+ /**
310
+ * `merge_precondition` (epic 44, US-44.1): the second run of both delivery gates over the real
311
+ * pull request. `exact_role: [:orchestrator, :user]`, so it takes the ORCH key the way
312
+ * `force_unclaim_story` does. It sends no `trio_outputs`: since contract 1.15.0 Gate A reads
313
+ * the lens verdicts triage persisted, and a trio sent here would be ignored.
314
+ */
315
+ export async function mergePrecondition(
316
+ { story_id, claim_epoch, effect_proof } = {},
317
+ { orchKey, apiCall } = {},
318
+ ) {
319
+ if (!orchKey) return refuse(MISSING_ORCH_KEY);
320
+
321
+ const bad = uuid(story_id, "story_id");
322
+ if (bad) return bad;
323
+
324
+ if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
325
+ return refuse("claim_epoch must be a non-negative integer: the epoch the caller acts under.");
326
+ }
327
+
328
+ const body = { claim_epoch };
329
+ if (effect_proof && typeof effect_proof === "object") body.effect_proof = effect_proof;
330
+
331
+ return apiCall("POST", mergePreconditionPath(story_id), body);
332
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.102.0",
3
+ "version": "2.104.1",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",