loopctl-mcp-server 3.0.0 → 3.2.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
@@ -527,12 +527,13 @@ The order to wire it up, the stage machine these tools move a story through, and
527
527
  | `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. |
528
528
  | `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`. |
529
529
  | `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`. |
530
+ | `change_binding_get` | **The chg changes bound to a story** (`GET /api/v1/stories/:id/changes`, loopctl US-47.7): every change a chg `submitted` event bound to the story, OLDEST FIRST so the current one is last, each with `repo`, `change_id`, `current`, `superseded_at`, `submitter_not_claimant`, `inserted_at`, and its APPLIED events in `event_seq` order (`event_seq`, `kind`, `revision`, `sha`, `occurred_at`, `applied_at`; never the event `data` payload). Bounded by the server, bounds in `meta` (`max_bindings`, `max_events_per_change`): the newest of each are kept, so the current binding is always listed, and `binding_count` / `applied_event_count` / `events_truncated` say when more exist. Any tenant key reads it (`role: :agent` with the hierarchy), so nothing is pinned: `LOOPCTL_API_KEY` when set, else `LOOPCTL_AGENT_KEY`, else `LOOPCTL_ORCH_KEY`. 404 when the story is not your tenant's — another tenant's id answers exactly like an unknown one; 401 without a valid key; 400 for a superadmin key without `X-Impersonate-Tenant`; no role is refused. Required: `story_id` (refused locally if not a UUID). |
530
531
  | `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`. |
531
532
  | `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). |
532
- | `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`. |
533
- | `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` is the merge route: `pr` (the default when omitted) is the only one; 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_checks` are the CI checks story verification judges a story's commit by, GitHub Actions job names, bounded in number and length by the server; `local-gate` is refused because whoever pushed posts it. Story verification judges a story's commit against the same `required_checks` in EITHER mode, satisfied only by a job of a push run of the story's branch; it first needs a GitHub credential for this tenant, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise, whatever the list holds); then a `pr` source that names none records `no_required_checks` and reads nothing, so naming them is how a pr source opts in, and a source that names some has its (tenant, repository) credential resolved before any GitHub read (`credential_unavailable` if there is none). Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, `mode`, `required_checks`, 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`. |
534
- | `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`), `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. |
535
- | `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, `mode` (`pr`, the only merge route), and `required_checks`, the checks story verification judges a story's commit by — once the tenant has a GitHub credential, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise), a source naming none records `no_required_checks` and reads nothing, and one naming some has its (tenant, repository) credential resolved before any GitHub read (`local-gate` is refused). 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. |
533
+ | `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, 409 `change_protocol_enrolled` when the story's intake source is in mode `chg` (the PR route is closed; land with `chg submit` — decided before any GitHub read, nothing escalated), 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`. |
534
+ | `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` is the merge route: `pr` (the default when omitted; the merge gate reads a pull request) or `chg` (Epic 47: the change protocol lands the repository's changes with `chg submit`, and `merge_precondition` refuses every story on the source with 409 `change_protocol_enrolled`); any other value, null included, is refused. A `chg` enrolment needs `change_events_secret` (or, preferred, `change_events_secret_file`, a path the tool reads so the value never enters the transcript) AND `chg_root_dispatch_id` in the same call — refused locally and 422 at the server without them — and a root that is not a parentless, unrevoked, orchestrator-role dispatch of this tenant is 422 `chg_root_invalid` (a child, a revoked or agent-role dispatch, another tenant's and an unknown id all alike). The change-events secret is write-only: no response and no tool result ever carries it. `required_checks` are the CI checks story verification judges a story's commit by, GitHub Actions job names, bounded in number and length by the server; `local-gate` is refused because whoever pushed posts it. Story verification judges a story's commit against the same `required_checks` in EITHER mode, satisfied only by a job of a push run of the story's branch; it first needs a GitHub credential for this tenant, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise, whatever the list holds); then a `pr` source that names none records `no_required_checks` and reads nothing, so naming them is how a pr source opts in, and a source that names some has its (tenant, repository) credential resolved before any GitHub read (`credential_unavailable` if there is none). Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, `mode`, `required_checks`, `change_events_secret` / `change_events_secret_file`, `chg_root_dispatch_id`, 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`. |
535
+ | `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 `chg`), `chg_root_dispatch_id`, `target_epic_id`, `revoked_at`, timestamps. **The webhook secret is not here and is not anywhere**, and neither is a `chg` source's change-events secret — 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. |
536
+ | `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, `mode` (`pr` or `chg`), a `chg` source's `change_events_secret` / `change_events_secret_file` and `chg_root_dispatch_id`, and `required_checks`, the checks story verification judges a story's commit by — once the tenant has a GitHub credential, its own token (`set_github_credential`) or an entry in the operator's `VERIFICATION_OPERATOR_TOKEN_TENANTS` (`credential_unavailable` otherwise), a source naming none records `no_required_checks` and reads nothing, and one naming some has its (tenant, repository) credential resolved before any GitHub read (`local-gate` is refused). 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. Each implement dispatch records the base branch it was placed under, and the merge gate reads that; `mode` is not bound that way — the gate reads the source's current mode. **Switching to `chg` closes the PR route for every story on the source at once** (409 `change_protocol_enrolled`; land with `chg submit`), and the switch itself is 409 `pr_route_in_flight`, naming `story_ids`, while a story of the project sits at `ci` holding a merge allow it has not merged on. The resulting source must carry both credentials, named here or kept from an earlier `chg` stint (422 naming the missing one), and the root is re-checked on every write that leaves the source `chg`, a secret rotation or a base-branch edit included (422 `chg_root_invalid`; switching to `pr` is never blocked by it). Switching to `pr` KEEPS both and ignores them; on a `pr` source an explicit null clears either, on a `chg` source it is a 422. Naming either credential, or setting `mode` to `chg` (which re-activates the kept ones), needs the tenant's operator key, one no dispatch minted (403 `chg_registration_forbidden` otherwise — a key minted by a dispatch beneath yours is refused the same way), and the secret never comes back. **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. |
536
537
  | `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. |
537
538
 
538
539
  ### Dispatch & Chain of Custody (v2) Tools
package/index.js CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  } from "./lib/intake-sources.js";
71
71
  import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
72
72
  import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
73
+ import { changeBindingGet as changeBindingGetRequest } from "./lib/change-bindings.js";
73
74
  import {
74
75
  clearGithubCredential as clearGithubCredentialRequest,
75
76
  githubCredential as githubCredentialRequest,
@@ -3432,6 +3433,22 @@ async function storyStage(args) {
3432
3433
  );
3433
3434
  }
3434
3435
 
3436
+ // US-47.7: a READ, `role: :agent` with the hierarchy, so any role's key clears the gate and
3437
+ // nothing is pinned. LOOPCTL_AGENT_KEY is passed as this tool's own key for the reason
3438
+ // storyStage gives above — `resolveKey` never reads it by name, and a session on an
3439
+ // agent-key-only config is exactly the one asking which change its story is bound to. It is an
3440
+ // OVERRIDE, not `exactKey`, so `resolveKey`'s order holds: LOOPCTL_API_KEY when set, else
3441
+ // LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY.
3442
+ async function changeBindingGet(args) {
3443
+ const key = process.env.LOOPCTL_AGENT_KEY;
3444
+
3445
+ return toContent(
3446
+ await changeBindingGetRequest(args, {
3447
+ apiCall: (method, path, body) => apiCall(method, path, body, key),
3448
+ }),
3449
+ );
3450
+ }
3451
+
3435
3452
  async function resolveEscalation(args) {
3436
3453
  return toContent(await resolveEscalationRequest(args, deliveryDeps()));
3437
3454
  }
@@ -8614,6 +8631,34 @@ const TOOLS = [
8614
8631
  required: ["story_id"],
8615
8632
  },
8616
8633
  },
8634
+ {
8635
+ name: "change_binding_get",
8636
+ description:
8637
+ "THE CHG CHANGES BOUND TO A STORY (GET /api/v1/stories/:id/changes, loopctl US-47.7): " +
8638
+ "every change a `submitted` chg event bound to the story, OLDEST FIRST so the current " +
8639
+ "one is last, each with `repo`, `change_id`, `current`, `superseded_at`, " +
8640
+ "`submitter_not_claimant` and `inserted_at`, plus that change's APPLIED events in " +
8641
+ "`event_seq` order (`event_seq`, `kind`, `revision`, `sha`, `occurred_at`, " +
8642
+ "`applied_at`). An event held ahead of a gap is not listed, and the event `data` " +
8643
+ "payload is never returned.\n\n" +
8644
+ "BOUNDED by the server, and the bounds are in `meta`: at most `max_bindings` bindings " +
8645
+ "and `max_events_per_change` events per change, the NEWEST of each, so the current " +
8646
+ "binding is always listed. `binding_count` and each binding's `applied_event_count` / " +
8647
+ "`events_truncated` say when more exist.\n\n" +
8648
+ "Any key of the tenant may read it (`role: :agent` with the hierarchy), so the key is " +
8649
+ "the usual order with LOOPCTL_AGENT_KEY as this tool's own: LOOPCTL_API_KEY when set, " +
8650
+ "else LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY. REFUSES: 404 when the story " +
8651
+ "is not your tenant's — another tenant's id answers exactly like an unknown one; 401 " +
8652
+ "for no key or one that does not authenticate; 400 for a superadmin key without " +
8653
+ "X-Impersonate-Tenant; 429 when rate limited. No 403: every key role clears the " +
8654
+ "`role: :agent` gate, so none is refused for its role. A malformed " +
8655
+ "`story_id` is refused locally (status 0, nothing sent).",
8656
+ inputSchema: {
8657
+ type: "object",
8658
+ properties: { story_id: { type: "string", description: "The story UUID." } },
8659
+ required: ["story_id"],
8660
+ },
8661
+ },
8617
8662
  {
8618
8663
  name: "resolve_escalation",
8619
8664
  description:
@@ -8661,7 +8706,10 @@ const TOOLS = [
8661
8706
  "REFUSALS. Needs an ORCHESTRATOR- or USER-role key: the action is `exact_role: " +
8662
8707
  "[:orchestrator, :user]`, so an agent key is 403'd. LOOPCTL_ORCH_KEY is sent when set, " +
8663
8708
  "else LOOPCTL_API_KEY. 403 `custody_tier_required` on a tenant without a human anchor, " +
8664
- "404 for an unknown story, 422 when `claim_epoch` is missing or the story is not at `ci` " +
8709
+ "404 for an unknown story, 409 `change_protocol_enrolled` when the story's intake source " +
8710
+ "is in mode `chg` (the PR route is closed for that repository — land the change with " +
8711
+ "`chg submit`; decided before the stage check and any GitHub read, and nothing is " +
8712
+ "escalated), 422 when `claim_epoch` is missing or the story is not at `ci` " +
8665
8713
  "or has no stage row. A `story_id` that is not a UUID or a bad `claim_epoch` is refused " +
8666
8714
  "here, before any call.",
8667
8715
  inputSchema: {
@@ -8792,8 +8840,15 @@ const TOOLS = [
8792
8840
  "does not exist, and the failure arrives after the claim. There is no cleared state for " +
8793
8841
  "it (a dispatch must name one), so null or blank is refused rather than falling back to " +
8794
8842
  "the default; intake_source_update changes it afterwards.\n\n" +
8795
- "`mode` is the merge route, and `pr` (the default) is the only one: the merge gate reads " +
8796
- "a pull request by number. Any other value, null included, is refused.",
8843
+ "`mode` is the merge route. `pr` (the default): the merge gate reads a pull request by " +
8844
+ "number. `chg` (Epic 47): the change protocol lands the repository's changes with " +
8845
+ "`chg submit`, and merge_precondition refuses every story on the source with 409 " +
8846
+ "`change_protocol_enrolled`. A `chg` enrolment needs `change_events_secret` (or " +
8847
+ "`change_events_secret_file`) AND `chg_root_dispatch_id` in the same call — refused " +
8848
+ "here, and 422 at the server, without them — and a root that is not a parentless, " +
8849
+ "unrevoked, orchestrator-role dispatch of this tenant is 422 `chg_root_invalid`. The " +
8850
+ "change-events secret is write-only: no response and no tool result ever carries it. " +
8851
+ "Any other mode, null included, is refused.",
8797
8852
  inputSchema: {
8798
8853
  type: "object",
8799
8854
  properties: {
@@ -8832,10 +8887,37 @@ const TOOLS = [
8832
8887
  },
8833
8888
  mode: {
8834
8889
  type: "string",
8835
- enum: ["pr"],
8890
+ enum: ["pr", "chg"],
8836
8891
  description:
8837
- "Optional. How this repository's changes reach its base branch: `pr`, the default " +
8838
- "and only value — the merge gate reads a pull request by number. Not nullable.",
8892
+ "Optional. How this repository's changes reach its base branch: `pr` (the " +
8893
+ "default; the merge gate reads a pull request by number) or `chg` (the change " +
8894
+ "protocol lands them and the PR route is closed, 409 `change_protocol_enrolled`; " +
8895
+ "needs the two fields below). Not nullable.",
8896
+ },
8897
+ change_events_secret: {
8898
+ type: "string",
8899
+ description:
8900
+ "WRITE-ONLY; required with `mode: chg`. The HMAC key chg-server signs its event " +
8901
+ "webhook with (POST /api/v1/change_events/:source_id); chg-server must hold the " +
8902
+ "same value. 32-256 bytes (422 otherwise). Encrypted at rest and NEVER returned by " +
8903
+ "any response or tool result. A tool input lands in the transcript, so prefer " +
8904
+ "`change_events_secret_file`. Not GitHub's webhook secret.",
8905
+ },
8906
+ change_events_secret_file: {
8907
+ type: "string",
8908
+ description:
8909
+ "Alternative to `change_events_secret`, and the preferred one: an absolute (or " +
8910
+ "`~/`) path the tool READS the secret from, so the value never passes through the " +
8911
+ "transcript. One trailing newline is stripped. Not both.",
8912
+ },
8913
+ chg_root_dispatch_id: {
8914
+ type: "string",
8915
+ description:
8916
+ "Required with `mode: chg`. The dispatch (UUID) chg-server's key was minted from: " +
8917
+ "a PARENTLESS, UNREVOKED, ORCHESTRATOR-role dispatch of this tenant, minted by the " +
8918
+ "operator key with `dispatch` and no parent. Anything else — a child, a revoked " +
8919
+ "or agent-role dispatch, another tenant's or an unknown id — is refused 422 " +
8920
+ "`chg_root_invalid`, all alike.",
8839
8921
  },
8840
8922
  required_checks: {
8841
8923
  type: "array",
@@ -8868,10 +8950,12 @@ const TOOLS = [
8868
8950
  description:
8869
8951
  "LIST THE TENANT'S GITHUB INTAKE SOURCES (GET /api/v1/intake/sources): per source its " +
8870
8952
  "`id` (the webhook URL is /api/v1/intake/github/<id>), `project_id`, `repo_full_name`, " +
8871
- "`base_branch`, `mode` (`pr`), `target_epic_id`, `revoked_at` and " +
8953
+ "`base_branch`, `mode` (`pr` or `chg`), `chg_root_dispatch_id`, `target_epic_id`, " +
8954
+ "`revoked_at` and " +
8872
8955
  "timestamps. Active sources only " +
8873
8956
  "unless `include_revoked` is true.\n\n" +
8874
- "THE WEBHOOK SECRET IS NOT HERE AND IS NOT ANYWHERE. It is returned once by " +
8957
+ "THE WEBHOOK SECRET IS NOT HERE AND IS NOT ANYWHERE, and neither is a `chg` source's " +
8958
+ "change-events secret, which is never returned at all. The webhook secret is returned once by " +
8875
8959
  "intake_source_enroll and the column is redacted on the schema, so this is not the way " +
8876
8960
  "to recover one — revoking and enrolling again is. Use this to find the source behind a " +
8877
8961
  "409 `no_intake_source` (the project is bound to none) or a 409 " +
@@ -8894,19 +8978,36 @@ const TOOLS = [
8894
8978
  description:
8895
8979
  "SET WHERE A SOURCE'S WORK LANDS (PATCH /api/v1/intake/sources/:id): `target_epic_id`, " +
8896
8980
  "the epic triaged stories are created in, `base_branch`, the branch every dispatch " +
8897
- "for this repository is cut FROM and carries, and `mode`, the merge route (`pr`, the " +
8898
- "only one).\n\n" +
8981
+ "for this repository is cut FROM and carries, `mode`, the merge route (`pr` or `chg`), " +
8982
+ "and a `chg` source's `change_events_secret` and `chg_root_dispatch_id`.\n\n" +
8899
8983
  "PRESENCE DECIDES, AND A FIELD YOU DO NOT NAME IS LEFT EXACTLY AS IT WAS. Naming " +
8900
8984
  "none of them is refused 422 `nothing_to_update` rather than being a silent no-op. DO NOT " +
8901
8985
  "SEND `target_epic_id: null` TO MEAN 'I AM NOT CHANGING THIS' — an explicit null is the " +
8902
8986
  "only way to CLEAR the epic, and clearing it returns the source to escalating every " +
8903
8987
  "report to a human instead of filing a story. Leave the field out instead. " +
8904
8988
  "`base_branch` and `mode` have no cleared state at all, so a null there is refused. " +
8905
- "The mode is BOUND to each implement dispatch when it is placed: a change affects " +
8906
- "only stories placed afterwards, and a story already placed keeps the mode its " +
8907
- "dispatch recorded, so a change is always allowed. `base_branch` is bound the same " +
8908
- "way: a story already placed is merge-gated against the base branch its dispatch " +
8909
- "was sent with.\n\n" +
8989
+ "`base_branch` is BOUND to each implement dispatch when it is placed: a story already " +
8990
+ "placed is merge-gated against the base branch its dispatch was sent with. `mode` is " +
8991
+ "NOT bound that way: the merge gate reads the source's current mode.\n\n" +
8992
+ "SWITCHING TO `chg` closes the PR route for EVERY story on the source at once: " +
8993
+ "merge_precondition reads the source's current mode and answers 409 " +
8994
+ "`change_protocol_enrolled` (land with `chg submit`). The switch is itself refused 409 " +
8995
+ "`pr_route_in_flight`, with the `story_ids`, while any story of the project sits at " +
8996
+ "`ci` holding a merge allow it has not merged on: merge those (or let the gate escalate " +
8997
+ "them) and retry. The resulting source must carry " +
8998
+ "BOTH `change_events_secret` and `chg_root_dispatch_id`, named here or kept from an " +
8999
+ "earlier `chg` stint (422 naming the missing one), and the root is re-checked on EVERY " +
9000
+ "write that leaves the source `chg` — a secret rotation or a base-branch edit included " +
9001
+ "— and whenever one is named (422 `chg_root_invalid` unless it is a parentless, " +
9002
+ "unrevoked, orchestrator-role dispatch of this tenant; a revoked root blocks every such " +
9003
+ "write until a live one is named, while switching to `pr` is never blocked). Switching " +
9004
+ "to `pr` KEEPS both and ignores " +
9005
+ "them; on a `pr` source an explicit null clears either, and on a `chg` source a null is " +
9006
+ "a 422. Naming either credential, or setting `mode` to `chg` (which re-activates the " +
9007
+ "kept ones), needs the tenant's operator key — a user-role key NO DISPATCH MINTED — " +
9008
+ "and anything else is 403 `chg_registration_forbidden`; a key minted by a dispatch " +
9009
+ "beneath yours is refused the same way. The secret is write-only and " +
9010
+ "never comes back.\n\n" +
8910
9011
  "THIS IS THE FIX FOR A SOURCE ALREADY POINTED AT THE WRONG TRUNK. intake_source_enroll " +
8911
9012
  "now takes `base_branch` itself, so a `main` repository is enrolled correctly in one " +
8912
9013
  "call; this is what corrects one that was not — a source enrolled before the parameter " +
@@ -8943,10 +9044,33 @@ const TOOLS = [
8943
9044
  },
8944
9045
  mode: {
8945
9046
  type: "string",
8946
- enum: ["pr"],
9047
+ enum: ["pr", "chg"],
8947
9048
  description:
8948
- "`pr`, the only merge route: the merge gate reads a pull request. Not nullable. " +
8949
- "Omit to leave the current value alone.",
9049
+ "`pr` (the merge gate reads a pull request) or `chg` (the change protocol lands " +
9050
+ "changes; the PR route is closed). Not nullable. Omit to leave the current value " +
9051
+ "alone.",
9052
+ },
9053
+ change_events_secret: {
9054
+ type: ["string", "null"],
9055
+ description:
9056
+ "WRITE-ONLY. The HMAC key chg-server signs its event webhook with, 32-256 bytes; " +
9057
+ "never returned. Null clears it on a `pr` source (422 on a `chg` one). Prefer " +
9058
+ "`change_events_secret_file`. Omit to leave it alone.",
9059
+ },
9060
+ change_events_secret_file: {
9061
+ type: "string",
9062
+ description:
9063
+ "Alternative to `change_events_secret`: an absolute (or `~/`) path the tool reads " +
9064
+ "the secret from, so it never passes through the transcript. One trailing newline " +
9065
+ "is stripped. Not both.",
9066
+ },
9067
+ chg_root_dispatch_id: {
9068
+ type: ["string", "null"],
9069
+ description:
9070
+ "The parentless, unrevoked, orchestrator-role dispatch of this tenant chg-server's " +
9071
+ "key was minted from; anything else is 422 `chg_root_invalid`, and the root a `chg` " +
9072
+ "source keeps is re-checked on every write to it. Null clears it on a " +
9073
+ "`pr` source (422 on a `chg` one). Omit to leave it alone.",
8950
9074
  },
8951
9075
  required_checks: {
8952
9076
  type: "array",
@@ -10066,6 +10190,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
10066
10190
  case "story_stage":
10067
10191
  return await storyStage(args);
10068
10192
 
10193
+ case "change_binding_get":
10194
+ return await changeBindingGet(args);
10195
+
10069
10196
  case "resolve_escalation":
10070
10197
  return await resolveEscalation(args);
10071
10198
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `change_binding_get` — `GET /api/v1/stories/:id/changes` (loopctl US-47.7, Epic 47).
3
+ *
4
+ * A story in a `chg`-mode repository is landed by the change protocol: chg-server delivers
5
+ * the change's events to loopctl, and a `submitted` event binds the change to the story. This
6
+ * is the read of that join — the session orchestrating the loop holds a story id and needs to
7
+ * see which change implements it and how far that change has got.
8
+ *
9
+ * ## What the endpoint answers
10
+ *
11
+ * The story's bindings OLDEST FIRST (the current one is last), each with `repo`,
12
+ * `change_id`, `current`, `superseded_at`, `submitter_not_claimant`, `inserted_at`, and its
13
+ * change's APPLIED events in `event_seq` order (`event_seq`, `kind`, `revision`, `sha`,
14
+ * `occurred_at`, `applied_at`). Both lists are bounded by the server and the bounds are in the
15
+ * response's `meta`: a truncated list keeps the NEWEST entries, `binding_count` and each
16
+ * binding's `applied_event_count` / `events_truncated` say when more exist. The event `data`
17
+ * payload is not returned.
18
+ *
19
+ * ## Refusals
20
+ *
21
+ * - `404` — the story is not the caller's tenant's. Another tenant's id answers EXACTLY like
22
+ * an id nobody issued; that is the isolation working, not a lookup failure to retry.
23
+ * - `401` — no key, or one that does not authenticate.
24
+ * - `400` — a superadmin key without `X-Impersonate-Tenant`.
25
+ * - `429` — rate limited.
26
+ *
27
+ * There is NO 403: the endpoint is `role: :agent` WITH the hierarchy, and every key role
28
+ * (agent, orchestrator, user, superadmin) is at least that, so no key is refused for its role.
29
+ *
30
+ * A malformed id is refused HERE with `status: 0` (no request sent), for the reason
31
+ * `delivery-loop.js`'s `uuid()` gives: the server would answer the same 404 as an unknown
32
+ * story, and the two have opposite remedies.
33
+ */
34
+
35
+ import { uuid } from "./delivery-loop.js";
36
+
37
+ export function storyChangesPath(storyId) {
38
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/changes`;
39
+ }
40
+
41
+ /** `GET /api/v1/stories/:id/changes`: the story's chg change bindings and applied events. */
42
+ export async function changeBindingGet({ story_id } = {}, { apiCall } = {}) {
43
+ const bad = uuid(story_id, "story_id");
44
+ if (bad) return bad;
45
+
46
+ return apiCall("GET", storyChangesPath(story_id), null);
47
+ }
@@ -84,7 +84,7 @@ function refuse(body) {
84
84
  * That 404 is the problem, not a 500. It is BYTE-IDENTICAL to the 404 for a perfectly
85
85
  * well-formed id that names no story — `FallbackController` answers `{:error, :not_found}` with
86
86
  * the same `%{error: %{status: 404, message: "Not found"}}`
87
- * (`lib/loopctl_web/fallback_controller.ex:74-78`) — so it cannot tell an operator which
87
+ * (`lib/loopctl_web/fallback_controller.ex:84-88`) — so it cannot tell an operator which
88
88
  * of the two happened, and the two have opposite remedies: re-read the argument you passed, or
89
89
  * go find the right story. The check here can tell them apart, and does it without a round trip.
90
90
  *
@@ -74,10 +74,28 @@
74
74
  * is. The controller mounts `RequireRole, role: :user` — the HIERARCHY form, so a
75
75
  * `:superadmin` key would pass too and this package holds no such variable — plus
76
76
  * `RequireHumanAnchor` on create/update/delete and `RequireUnlineagedCaller` on create
77
- * (`lib/loopctl_web/controllers/intake_source_controller.ex:27-29`). That last one is why a
77
+ * (`lib/loopctl_web/controllers/intake_source_controller.ex:34-36`). That last one is why a
78
78
  * global `LOOPCTL_API_KEY` must not stand in: creating a source MINTS A CREDENTIAL that
79
79
  * belongs to no dispatch lineage, so a caller whose own key a dispatch minted is refused
80
- * `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`.
80
+ * `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`. An update that
81
+ * names a `chg` credential or sets `mode` to `chg` is held to the same ceiling under its own
82
+ * code, `403 chg_registration_forbidden`, whose remedy is the operator key itself.
83
+ *
84
+ * ## MODE `chg` AND ITS TWO CREDENTIALS (US-47.2, AC-47.7.2)
85
+ *
86
+ * A source in `mode: "chg"` has its changes landed by the change protocol (`chg submit`), and
87
+ * loopctl's merge precondition refuses every story on it with `409 change_protocol_enrolled`.
88
+ * It needs BOTH `change_events_secret` — the HMAC key chg-server signs its event webhook with,
89
+ * which the OPERATOR supplies because chg-server must hold the same value — and
90
+ * `chg_root_dispatch_id`, the parentless orchestrator-role dispatch chg-server's key was minted
91
+ * from (`422 chg_root_invalid` for anything else, another tenant's included).
92
+ *
93
+ * The secret is WRITE-ONLY on both sides: the server never serialises it, and `publicSource`
94
+ * below never names it, so no tool result carries it. A tool INPUT is recorded in the
95
+ * transcript too, so `change_events_secret_file` is offered beside it: the tool reads the
96
+ * value from that file and it never passes through a transcript at all. Exactly one trailing
97
+ * newline is stripped (what `openssl rand -hex 32 > file` writes); chg-server must hold the
98
+ * value without it.
81
99
  *
82
100
  * ## `repo_full_name` IS NOT SHAPE-CHECKED HERE, AND THAT IS A DECISION
83
101
  *
@@ -144,18 +162,135 @@ function epicRefusal(value, { clearable }) {
144
162
  }
145
163
 
146
164
  /**
147
- * The merge routes a source may take: `pr`, the default and the only one (#984 retired
148
- * `thread`). NOT nullable — the server answers 422 — so a null is refused here where the
149
- * reason can be stated.
165
+ * The merge routes a source may take: `pr`, the default, and `chg` (US-47.2), where the change
166
+ * protocol lands the repository's changes and the PR route is closed. NOT nullable — the
167
+ * server answers 422 — so a null is refused here where the reason can be stated.
150
168
  */
151
- export const SOURCE_MODES = ["pr"];
169
+ export const SOURCE_MODES = ["pr", "chg"];
152
170
 
153
171
  export function modeRefusal(mode) {
154
172
  if (SOURCE_MODES.includes(mode)) return null;
155
173
 
156
174
  return refuse(
157
- "`mode` must be `pr`, or must be left out entirely (enrolment then takes `pr`, and an " +
158
- "update keeps the current value). It cannot be null.",
175
+ "`mode` must be `pr` or `chg`, or must be left out entirely (enrolment then takes `pr`, " +
176
+ "and an update keeps the current value). It cannot be null.",
177
+ );
178
+ }
179
+
180
+ /**
181
+ * The `chg` credentials a call NAMED, resolved into the request body fields, or a refusal.
182
+ *
183
+ * Resolves to `{ fields }` or `{ refusal }`. `clearable` is the update path, where an explicit
184
+ * null clears a credential on a `pr` source (the server refuses it on a `chg` one). No branch
185
+ * of this function puts the secret's value in a message.
186
+ */
187
+ export async function chgCredentials(args, { clearable, fs = defaultFs, homedir = os.homedir() }) {
188
+ const fields = {};
189
+ const { change_events_secret: secret, change_events_secret_file: secretFile } = args;
190
+
191
+ if (secret !== undefined && secretFile !== undefined) {
192
+ return {
193
+ refusal: refuse(
194
+ "Send `change_events_secret` OR `change_events_secret_file`, not both: they are two " +
195
+ "ways of naming the same value.",
196
+ ),
197
+ };
198
+ }
199
+
200
+ if (secret !== undefined) {
201
+ if (secret === null && clearable) {
202
+ fields.change_events_secret = null;
203
+ } else if (typeof secret !== "string" || secret === "") {
204
+ return {
205
+ refusal: refuse(
206
+ "`change_events_secret` must be a non-empty string (the server holds it to " +
207
+ "32-256 bytes)" +
208
+ (clearable ? ", or null to clear it on a `pr` source." : ".") +
209
+ " Prefer `change_events_secret_file`, which keeps the value out of the transcript.",
210
+ ),
211
+ };
212
+ } else {
213
+ fields.change_events_secret = secret;
214
+ }
215
+ }
216
+
217
+ if (secretFile !== undefined) {
218
+ const read = await readSecretFile(secretFile, { fs, homedir });
219
+ if (read.refusal) return read;
220
+ fields.change_events_secret = read.secret;
221
+ }
222
+
223
+ const root = args.chg_root_dispatch_id;
224
+
225
+ if (root !== undefined) {
226
+ if (root === null && clearable) {
227
+ fields.chg_root_dispatch_id = null;
228
+ } else {
229
+ const badRoot = uuidRefusal(root, "chg_root_dispatch_id");
230
+ if (badRoot) return { refusal: badRoot };
231
+ fields.chg_root_dispatch_id = root;
232
+ }
233
+ }
234
+
235
+ return { fields };
236
+ }
237
+
238
+ async function readSecretFile(secretFile, { fs, homedir }) {
239
+ if (typeof secretFile !== "string" || secretFile.trim() === "") {
240
+ return {
241
+ refusal: refuse("`change_events_secret_file` must be a path to the file holding the secret."),
242
+ };
243
+ }
244
+
245
+ const path = expandHome(secretFile, homedir);
246
+ if (!nodePath.isAbsolute(path)) {
247
+ return {
248
+ refusal: refuse(
249
+ `change_events_secret_file must be absolute or start with ~/ (got '${secretFile}').`,
250
+ ),
251
+ };
252
+ }
253
+
254
+ let raw;
255
+ try {
256
+ raw = await fs.readFile(path, "utf8");
257
+ } catch (err) {
258
+ return {
259
+ refusal: refuse(
260
+ `Could not read change_events_secret_file '${path}' (${err?.code || "error"}). ` +
261
+ "Nothing was sent.",
262
+ ),
263
+ };
264
+ }
265
+
266
+ const secret = raw.replace(/\r?\n$/, "");
267
+ if (secret === "") {
268
+ return { refusal: refuse(`change_events_secret_file '${path}' is empty. Nothing was sent.`) };
269
+ }
270
+
271
+ return { secret };
272
+ }
273
+
274
+ /**
275
+ * `mode: "chg"` at ENROLMENT needs both credentials in the same call (there is no earlier
276
+ * stint to keep them from), so their absence is refused here, before a secret file is
277
+ * reserved, with the 422 the server would answer named.
278
+ */
279
+ function chgEnrolmentRefusal(mode, fields) {
280
+ if (mode !== "chg") return null;
281
+
282
+ const missing = ["change_events_secret", "chg_root_dispatch_id"].filter(
283
+ (field) => fields[field] === undefined,
284
+ );
285
+
286
+ if (missing.length === 0) return null;
287
+
288
+ return refuse(
289
+ `\`mode: chg\` needs ${missing.map((f) => `\`${f}\``).join(" and ")} in the same call; ` +
290
+ "the server answers 422 without them. `chg_root_dispatch_id` is the parentless " +
291
+ "orchestrator-role dispatch chg-server's key was minted from (the operator mints it " +
292
+ "with `dispatch`, no parent), and the secret is the HMAC key chg-server signs its " +
293
+ "events with (`change_events_secret_file` keeps it out of the transcript).",
159
294
  );
160
295
  }
161
296
 
@@ -221,6 +356,8 @@ export function publicSource(source) {
221
356
  base_branch: source.base_branch,
222
357
  mode: source.mode,
223
358
  required_checks: source.required_checks,
359
+ // `change_events_secret` is NEVER named here: write-only (US-47.2).
360
+ chg_root_dispatch_id: source.chg_root_dispatch_id,
224
361
  target_epic_id: source.target_epic_id,
225
362
  revoked_at: source.revoked_at,
226
363
  inserted_at: source.inserted_at,
@@ -255,6 +392,9 @@ export async function enrollIntakeSource(
255
392
  mode,
256
393
  required_checks,
257
394
  secret_file,
395
+ change_events_secret,
396
+ change_events_secret_file,
397
+ chg_root_dispatch_id,
258
398
  } = {},
259
399
  { userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
260
400
  ) {
@@ -309,6 +449,16 @@ export async function enrollIntakeSource(
309
449
  if (badChecks) return badChecks;
310
450
  }
311
451
 
452
+ // The `chg` credentials (US-47.2), resolved and judged before a secret file is reserved.
453
+ const chg = await chgCredentials(
454
+ { change_events_secret, change_events_secret_file, chg_root_dispatch_id },
455
+ { clearable: false, fs, homedir },
456
+ );
457
+ if (chg.refusal) return chg.refusal;
458
+
459
+ const badChg = chgEnrolmentRefusal(mode, chg.fields);
460
+ if (badChg) return badChg;
461
+
312
462
  if (typeof secret_file !== "string" || secret_file.trim() === "") {
313
463
  return refuse(
314
464
  "`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
@@ -360,6 +510,7 @@ export async function enrollIntakeSource(
360
510
  if (base_branch !== undefined) body.base_branch = base_branch;
361
511
  if (mode !== undefined) body.mode = mode;
362
512
  if (required_checks !== undefined) body.required_checks = required_checks;
513
+ Object.assign(body, chg.fields);
363
514
 
364
515
  let result;
365
516
  try {
@@ -514,7 +665,10 @@ export async function listIntakeSources({ include_revoked } = {}, { userKey, api
514
665
  * uses `empty_values: []` precisely so that a caller who SENT a value gets an answer about
515
666
  * the value it sent, which is a 422.
516
667
  */
517
- export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
668
+ export async function updateIntakeSource(
669
+ args = {},
670
+ { userKey, apiCall, fs = defaultFs, homedir = os.homedir() } = {},
671
+ ) {
518
672
  if (!userKey) return refuse(MISSING_USER_KEY);
519
673
 
520
674
  const bad = uuidRefusal(args.source_id, "source_id");
@@ -553,11 +707,20 @@ export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
553
707
  body.required_checks = args.required_checks;
554
708
  }
555
709
 
710
+ // Switching to `chg` may rely on credentials an earlier `chg` stint left on the row, so their
711
+ // absence here is the SERVER's to judge (422 naming the missing one); only their shape is
712
+ // judged here.
713
+ const chg = await chgCredentials(args, { clearable: true, fs, homedir });
714
+ if (chg.refusal) return chg.refusal;
715
+ Object.assign(body, chg.fields);
716
+
556
717
  if (Object.keys(body).length === 0) {
557
718
  return refuse(
558
719
  "Nothing to update. Name at least one of `target_epic_id` (null clears it), " +
559
- "`base_branch`, `mode` or `required_checks`. A body carrying none of them is refused 422 " +
560
- "`nothing_to_update`, because a field you do not send is left exactly as it was.",
720
+ "`base_branch`, `mode`, `required_checks`, `change_events_secret` (or " +
721
+ "`change_events_secret_file`) or `chg_root_dispatch_id`. A body carrying none of them " +
722
+ "is refused 422 `nothing_to_update`, because a field you do not send is left exactly " +
723
+ "as it was.",
561
724
  );
562
725
  }
563
726
 
@@ -72,7 +72,7 @@
72
72
  * (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35`), and the body is
73
73
  * `LoopctlWeb.ErrorJSON`'s generic `{"error": {"status": 404, "message": "Not found"}}` — byte
74
74
  * for byte what `FallbackController` answers for a well-formed id naming no story
75
- * (`lib/loopctl_web/fallback_controller.ex:74-78`). Two opposite remedies, one answer.
75
+ * (`lib/loopctl_web/fallback_controller.ex:84-88`). Two opposite remedies, one answer.
76
76
  *
77
77
  * The check is `uuid()`, IMPORTED from `lib/delivery-loop.js` rather than reimplemented, so the
78
78
  * refusal a caller reads for a bad `story_id` does not depend on which tool they reached for.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",