loopctl-mcp-server 2.116.0 → 3.1.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 +6 -13
- package/index.js +114 -414
- package/lib/delivery-loop.js +1 -1
- package/lib/github-credential.js +2 -2
- package/lib/intake-sources.js +181 -22
- package/lib/story-update.js +1 -1
- package/package.json +1 -1
- package/lib/threads.js +0 -295
package/README.md
CHANGED
|
@@ -249,7 +249,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
249
249
|
| `get_epic` | **Read one epic** with its stories (`GET /api/v1/epics/:id`). Any key; 404 outside your tenant. |
|
|
250
250
|
| `update_epic` | **Correct an epic** (`PATCH /api/v1/epics/:id`): `title`, `description`, `phase`, `position`, `metadata`; `number` is fixed. Absent and null fields are dropped by the endpoint, so a null and a call naming no field are refused locally (an empty string is what clears `description` or `phase`), and **`metadata` REPLACES the stored map whole** — read it first. `LOOPCTL_ORCH_KEY` on a human-anchored tenant. |
|
|
251
251
|
| `delete_epic` | **Delete an epic and every story in it** (`DELETE /api/v1/epics/:id`) — irreversible, the delete cascades. 204 with no body. `LOOPCTL_USER_KEY` (user or above; sent verbatim when set, else the default key) on a human-anchored tenant. 422 when an active intake source targets the epic (revoke it first), and 422 when a dispatch, capability token or verification run references any story in it (those do not cascade). |
|
|
252
|
-
| `delete_story` | **Delete one story** (`DELETE /api/v1/stories/:id`, loopctl #876) — irreversible. 204 with no body. `LOOPCTL_USER_KEY` (sent verbatim when set, else the default key) on a human-anchored tenant. 422 when a dispatch, capability token or verification run references the story (those do not cascade)
|
|
252
|
+
| `delete_story` | **Delete one story** (`DELETE /api/v1/stories/:id`, loopctl #876) — irreversible. 204 with no body. `LOOPCTL_USER_KEY` (sent verbatim when set, else the default key) on a human-anchored tenant. 422 when a dispatch, capability token or verification run references the story (those do not cascade). |
|
|
253
253
|
| `epic_progress` | **An epic's progress** (`GET /api/v1/epics/:id/progress`): its stories counted by `agent_status` and `verified_status`. Any key. |
|
|
254
254
|
| `backfill_story` | **Bypasses the review/verify chain.** Marks a story as verified when the work was completed outside loopctl (e.g. before the project was onboarded). Refused for any story with `assigned_agent_id` set — those must go through the normal report → review → verify flow, not backfill. Also refused for already `:verified` or `:rejected` stories. Records provenance (`reason`, `evidence_url`, `pr_number`) in `metadata.backfill` and emits a `story.backfilled` webhook. |
|
|
255
255
|
| `get_acceptance_criteria` | List a story's acceptance criteria with each one's verification status. Required: `story_id`. |
|
|
@@ -417,7 +417,7 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
417
417
|
| `llm_config` | **Check your onboarding status.** Get the tenant's BYO LLM config: per-operation models and whether each key is set — `has_api_key` (Anthropic) / `has_embedding_key` (OpenAI embedding) — plus masked last-4 hints. Never returns a key. Requires **user** key. |
|
|
418
418
|
| `set_llm_config` | **First-time setup (do once).** Set/rotate the tenant's OWN **Anthropic `api_key`** (powers ingest) AND **OpenAI `embedding_api_key`** (powers semantic search) — both stored encrypted, never returned — plus the per-operation models (`extraction_model`/`classification_model`/`merge_model`/`embedding_model`). Any subset; partial-merge (omitting a key leaves it untouched). **Private tier (US-41.3):** also sets `chat_provider` (`anthropic` | `openai_compatible`), `chat_base_url`, `extraction_model` (required on that provider) and `chat_api_key` (optional — omit it for a keyless local server) so ingest extraction / classification / merge / memory promotion run against YOUR OWN OpenAI-compatible endpoint and document text never leaves your boundary. Every model the config resolves to is PROBED with a trivial completion before it is saved (a 422 persists nothing); the probe re-runs on any chat-surface change (provider, base_url, key rotation, model), a private-range host is refused by the SSRF guard, and moving to a NEW host needs a matching `chat_api_key` or `acknowledge_key_transmission: true`. See [First-time setup](#first-time-setup--provision-your-byo-llm-keys). Requires **user** key. |
|
|
419
419
|
| `github_credential` | **Is there a GitHub credential for this tenant?** (#936, `GET /api/v1/tenants/me/github-credential`) `has_token`, a masked last-4 `token_hint`, `updated_at`, and `operator_repositories`: the repositories the operator's token is lent for (`VERIFICATION_OPERATOR_TOKEN_TENANTS`) while the tenant has no token. Never returns the token. Reach for it when the merge gate refuses `{pull_request_unavailable, credential_unavailable}` or verification records `credential_unavailable`. Requires **user** key. |
|
|
420
|
-
| `set_github_credential` | **Set or replace the tenant's own GitHub token** (#936, `PUT /api/v1/tenants/me/github-credential`). Every GitHub call loopctl makes for the tenant — merge gate, story and post-deploy verification,
|
|
420
|
+
| `set_github_credential` | **Set or replace the tenant's own GitHub token** (#936, `PUT /api/v1/tenants/me/github-credential`). Every GitHub call loopctl makes for the tenant — merge gate, story and post-deploy verification, issue closing — then uses it for EVERY repository, replacing the operator's token even where the operator lent it. A fine-grained token on your own repositories with read access to Contents, Pull requests, Actions, Commit statuses and Deployments (plus Issues: write for issue closing). Stored encrypted, never returned, and not checked against GitHub on the way in: a token that cannot read a repository shows up as that repository's forge refusal. 422 when blank, over the server's length bound or containing anything but letters, digits and underscores; `403 custody_tier_required` on an agent-rooted tenant. Required: `token`. Requires **user** key. |
|
|
421
421
|
| `clear_github_credential` | **Remove the tenant's GitHub token** (#936, `DELETE /api/v1/tenants/me/github-credential`). Calls then use the operator's token only for `operator_repositories` and are refused `credential_unavailable` everywhere else, so a pr-mode repository outside that list stops merging. Idempotent. Requires **user** key. |
|
|
422
422
|
| `knowledge_llm_usage` | Per-tenant LLM token-usage summary, grouped by operation + model + source_type + day over an optional `from`/`to` range (defaults to a 90-day lookback; effective window echoed in `meta.from`/`meta.to`), with `limit`/`offset` pagination. Record-only (no budget enforcement). Requires orchestrator key. |
|
|
423
423
|
|
|
@@ -528,18 +528,11 @@ The order to wire it up, the stage machine these tools move a story through, and
|
|
|
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
530
|
| `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
|
-
| `thread_get` | **Read a story's change thread** (`GET /api/v1/stories/:id/thread`, any role): its latest page of checkpoints (`checkpoints_truncated` is true when older ones exist), and its entries in `seq` order, paged with `after_seq` / `limit` (the response's `next_after_seq` is the next page's `after_seq`). Every entry body is untrusted text (`body_untrusted: true`). Each entry's `kb_refs` lists the knowledge article ids it cited. |
|
|
532
|
-
| `thread_checkpoint` | **Record a checkpoint on the story you hold** (`POST /api/v1/stories/:id/thread/checkpoints`): a pushed commit plus your reasoning in `note`, sent on the key `claim_story` claims with (`LOOPCTL_API_KEY` when set, else `LOOPCTL_AGENT_KEY`). Refusals: 409 `not_claimant`; 409 `stale_claim_epoch` or `claim_not_live` (your claim no longer accepts work: it ended, its lease lapsed, you requested review or reported it); 409 `checkpoint_conflict` (recorded under your claim with another tree or note); 422 for a malformed sha, a commit and tree of different object formats, an empty or oversized note (bytes), or a note carrying a credential (`secret_blocked`). Idempotent on (`commit_sha`, `claim_epoch`). Optional `kb_refs`: the knowledge article ids the checkpoint relied on (at most 20 distinct, each readable by this key: your tenant's or a published system canonical, else 422 `unknown_kb_refs`; a resend with other `kb_refs` is 409 `checkpoint_conflict`). Required: `story_id`, `claim_epoch`, `commit_sha`, `tree_sha`. |
|
|
533
|
-
| `thread_entry` | **Write a message on a story's thread** (`POST /api/v1/stories/:id/thread/entries`): kind `message`, from any principal, optionally naming a `checkpoint_id` of this story. Findings and verdicts come over the runner socket from a review dispatch, fixes through `thread_fix`, and review requests by the request-review flow; all are refused here. A credential in the body or key is 422 `secret_blocked`. Idempotent per author on `idempotency_key` for the same write; a different entry on the same key is 409 `idempotency_key_reused`, and keys starting `loopctl:` are reserved. Optional `kb_refs`: the knowledge article ids the message acted on (at most 20 distinct, each readable by this key: your tenant's or a published system canonical, else 422 `unknown_kb_refs`); `thread_get` returns them on each entry and `thread_review_get` names each article's title beside the entry. `principal` picks the key: agent (default, the key `claim_story` uses), orchestrator or user. |
|
|
534
|
-
| `thread_request_review` | **Request a review of a story's thread** (`POST /api/v1/stories/:id/thread/reviews`, `LOOPCTL_ORCH_KEY`, or `LOOPCTL_USER_KEY` with `principal: user`): loopctl places it on `runner_id` as a runner dispatch of kind `review` (the runner must declare `review`), for the next round, on the story's latest checkpoint of the current claim. It claims nothing and returns no credential; the review's findings and verdict arrive over that runner's socket. Rounds are counted per claim. Round 3 only when a material round-2 finding names a checkpoint carrying a round-1 fix recorded before round 2 was placed; never round 4. Idempotent on `dispatch_id`. Refusals: 403 for an agent key; 409 `no_checkpoint`, `review_claim_ended`, `review_ceiling_reached`, `reviewer_not_separate`, `unresolvable_dispatch_lineage` (the implementer's lineage cannot be read, so no reviewer can be shown separate), `dispatch_id_conflict`, `review_dispatch_refused` (the runner refused or superseded that review: place a new one with a new `dispatch_id`) and the push's own (`runner_not_connected`, `kind_not_supported`, `budget_unset`, ...); 422 `invalid_uuid` for a malformed `runner_id` or `dispatch_id`; 429 at capacity; 503 `tenant_halted`. Required: `story_id`, `runner_id`. |
|
|
535
|
-
| `thread_review_get` | **Read a review's payload** (`GET /api/v1/stories/:id/thread/reviews/:review_id`, any role): the story, the checkpoint it reads with its diff reference, the thread's latest entries, the latest fixes with the findings each answers (`fixes_truncated` when older ones exist), and the rounds. Each entry carries `kb_articles`: the articles it cited, in order, with `title`, `category` and `status`, or `readable: false` for one the key cannot read. Bodies, locations and article titles are untrusted. |
|
|
536
|
-
| `thread_fix` | **Record a fix on the story you hold** (`POST /api/v1/stories/:id/thread/fixes`), on the key `claim_story` claims with: the checkpoint carrying it (one your current claim recorded after every checkpoint its findings were found in) and the findings of completed rounds it answers. Refusals: 409 `not_claimant`, `stale_claim_epoch`, `claim_not_live`, `idempotency_key_reused`; 422 `fix_checkpoint_required`, `fix_checkpoint_not_current_claim`, `fix_checkpoint_not_after_findings`, `finding_ids_required`, `unknown_finding`, `secret_blocked`; 503 `tenant_halted`. |
|
|
537
|
-
| `thread_stage_report` | **Report a stage transition of the story you claimed yourself on a thread-mode repository** (`POST /api/v1/stories/:id/stage/transitions`, US-45.9): an interactive claim of a thread-mode story is a change thread with no runner, so its claimant moves the delivery stage as a runner would (with the checkpoint's `head_sha` only entering `ci`), never into `merged` and never `merge_refused`: loopctl's App makes and records the merge; after it, `merged` -> `deployed` with the `release_id`. The first report (from `claimed`) enters the claim. A resend of a report that already landed answers 200 with `replayed: true`. On the key `claim_story` claims with. Refusals: 404; 409 `not_claimant`, `stale_claim_epoch`, `claim_not_live` (renew the lease with `renew_story_claim` while you work), `not_interactive_thread_claim`, `effect_conflict` (the body names the recorded `head_sha`), `stale_stage` (the body names the story's stage and recorded effects); 422 `invalid_payload`; 503 `busy`. |
|
|
538
531
|
| `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). |
|
|
539
|
-
| `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`).
|
|
540
|
-
| `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`
|
|
541
|
-
| `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 `
|
|
542
|
-
| `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 `
|
|
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, 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`. |
|
|
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; 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`. |
|
|
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` 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. |
|
|
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` 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. |
|
|
543
536
|
| `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. |
|
|
544
537
|
|
|
545
538
|
### Dispatch & Chain of Custody (v2) Tools
|