loopctl-mcp-server 3.1.0 → 3.3.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 +10 -7
- package/index.js +101 -13
- package/lib/change-bindings.js +47 -0
- package/lib/custody-key.js +2 -2
- package/lib/delivery-loop.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -259,7 +259,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
259
259
|
| Tool | Description |
|
|
260
260
|
|---|---|
|
|
261
261
|
| `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. Refused 409 `story_held` when the story's delivery stage is `escalated`, `done` or `failed` — it is not available to agents even when it reads `pending`; an escalated story becomes available again only after `resolve_escalation` sends it to `queued`. |
|
|
262
|
-
| `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. Refused 409 `story_held` when the story's delivery stage is `escalated`, `done` or `failed` — even when it reads `pending` or `contracted`; an escalated story is claimable again only after `resolve_escalation` sends it to `queued`, a done or failed one never is. |
|
|
262
|
+
| `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. Refused 409 `story_held` when the story's delivery stage is `escalated`, `done` or `failed` — even when it reads `pending` or `contracted`; an escalated story is claimable again only after `resolve_escalation` sends it to `queued`, a done or failed one never is. Refused 409 `chg_root_review_only` when the key descends from an intake source's `chg_root_dispatch_id`: chg-server's keys may only review. |
|
|
263
263
|
| `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. A driver-placed claim — one a placement took for a runner dispatch — is capped at its dispatch deadline (`claim_lease_cap`: placed_at + `wall_clock_seconds` + `DISPATCH_LEASE_GRACE_SECONDS` at the claim, sent to the runner as `deadline_at`; moved forward to a resume's time or the runner's acceptance + `wall_clock_seconds` + the grace, only while the claim is live; never earlier than a `deadline_at` already sent), and its `claimed_until` already IS that cap: renewing it writes nothing and returns the claim as it stands, never moving `claimed_until` past it. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`, 409 `lease_cap_reached` (a driver-placed claim already at its cap: nothing is renewed, stop working it). Needs a loopctl server with claim leases. |
|
|
264
264
|
| `escalate_story` | Park a story you hold for a HUMAN and stop (`POST /stories/:id/escalate`, agent key). This is what an unattended session calls INSTEAD of asking a question — a headless run has no way to ask, so escalating is the affordance. Use it when the request contradicts an existing story, KB decision or documented behaviour, when it inverts or removes behaviour a previous story deliberately added, when it is a workflow change rather than a defect fix, or when it needs a business call — not for an ordinary blocker. ONE-WAY from your side: only a human moves the story off `escalated`, so on success STOP and report. Required: `story_id`, `claim_epoch`, `reason` (verbatim, treated as untrusted data, length capped by the server in codepoints — a 400 names the bound); optional `payload` object. Idempotent under the same epoch. Refusals pass through: 409 `stale_claim_epoch`, 409 `not_claimant`, 404 `unknown_story_stage`, 400 for a malformed epoch or reason. An orchestrator or user key is 403'd on purpose. |
|
|
265
265
|
| `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
|
|
@@ -269,22 +269,22 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
|
|
|
269
269
|
|
|
270
270
|
| Tool | Description |
|
|
271
271
|
|---|---|
|
|
272
|
-
| `report_story` | Reviewer confirms the implementation is done. Transitions implementing -> reported_done. Accepts optional `token_usage` object. Under the LCP-1 §9.3 signed profile, pass the `claim` from `custody_sign_claim`. |
|
|
273
|
-
| `review_complete` | Record that a review has been completed for a story. Required before verify. Under the signed profile, pass the `claim` from `custody_sign_claim`. |
|
|
272
|
+
| `report_story` | Reviewer confirms the implementation is done. Transitions implementing -> reported_done. Accepts optional `token_usage` object. Under the LCP-1 §9.3 signed profile, pass the `claim` from `custody_sign_claim`. Refused 409 `chg_root_review_only` for a key descending from an intake source's `chg_root_dispatch_id`. |
|
|
273
|
+
| `review_complete` | Record that a review has been completed for a story. Required before verify. Under the signed profile, pass the `claim` from `custody_sign_claim`. On a story bound to a chg change (loopctl US-47.5), after the existing custody gates: only a key chg-server minted for the review — a strict descendant of the intake source's `chg_root_dispatch_id` — may record it (409 `chg_reviewer_required`, the root key included); `commit_sha` is REQUIRED and must be the sha of a submitted revision of the story's current change (422 `commit_sha_not_bound`); one reviewer dispatch records one verdict (409 `chg_review_already_recorded`). `commit_sha` is ignored on any other story. |
|
|
274
274
|
|
|
275
275
|
### Verification Tools (orchestrator key)
|
|
276
276
|
|
|
277
277
|
| Tool | Description |
|
|
278
278
|
|---|---|
|
|
279
|
-
| `verify_story` | Orchestrator verifies a reported_done story. Transitions reported_done -> verified. Under the signed profile, pass the `claim` from `custody_sign_claim`. |
|
|
280
|
-
| `reject_story` | Orchestrator rejects a story with a reason. The reject releases the claim, and a delivery story may be re-contracted or escalated in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain entry was refused) means the WHOLE call rolled back: the story is unchanged, and a re-run meets the same server-side condition until an operator acts. |
|
|
279
|
+
| `verify_story` | Orchestrator verifies a reported_done story. Transitions reported_done -> verified. Under the signed profile, pass the `claim` from `custody_sign_claim`. Refused 409 `chg_root_cannot_verify` for a key descending from an intake source's `chg_root_dispatch_id` (loopctl US-47.5). |
|
|
280
|
+
| `reject_story` | Orchestrator rejects a story with a reason. The reject releases the claim, and a delivery story may be re-contracted or escalated in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain entry was refused) means the WHOLE call rolled back: the story is unchanged, and a re-run meets the same server-side condition until an operator acts. Refused 409 `chg_root_cannot_verify` for a key descending from an intake source's `chg_root_dispatch_id` (loopctl US-47.5). |
|
|
281
281
|
|
|
282
282
|
### Bulk Tools (orchestrator key)
|
|
283
283
|
|
|
284
284
|
| Tool | Description |
|
|
285
285
|
|---|---|
|
|
286
|
-
| `bulk_mark_complete` | **Backfill-only.** Bulk-marks pre-existing, never-dispatched stories (pending, unassigned, no dispatch lineage) as complete in a single call. Dispatched stories are refused — they must go through the normal report → review → verify flow. |
|
|
287
|
-
| `verify_all_in_epic` | Bulk verify all reported_done, unverified stories in an epic. |
|
|
286
|
+
| `bulk_mark_complete` | **Backfill-only.** Bulk-marks pre-existing, never-dispatched stories (pending, unassigned, no dispatch lineage) as complete in a single call. Dispatched stories are refused — they must go through the normal report → review → verify flow. Refused 409 `chg_root_cannot_verify` for the whole batch for a chg-rooted key (loopctl US-47.5). |
|
|
287
|
+
| `verify_all_in_epic` | Bulk verify all reported_done, unverified stories in an epic. Refused 409 `chg_root_cannot_verify` for the whole epic for a chg-rooted key (loopctl US-47.5). |
|
|
288
288
|
|
|
289
289
|
### Token Efficiency Tools
|
|
290
290
|
|
|
@@ -527,6 +527,7 @@ 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
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`. |
|
|
@@ -663,6 +664,8 @@ loopctl enforces that nobody marks their own work as done. The API returns `409`
|
|
|
663
664
|
|
|
664
665
|
The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
|
|
665
666
|
|
|
667
|
+
On a story bound to a chg change (loopctl US-47.5) the review comes from chg-server: `review_complete` takes it only from a key chg-server minted beneath its registered `chg_root_dispatch_id` (409 `chg_reviewer_required`), naming the reviewed revision as `commit_sha` (422 `commit_sha_not_bound`), one verdict per reviewer dispatch (409 `chg_review_already_recorded`). chg-server's keys never certify or implement: `verify_story`, `reject_story`, `verify_all_in_epic` and `bulk_mark_complete` answer 409 `chg_root_cannot_verify`, `claim_story` and `report_story` 409 `chg_root_review_only`. A chg story that lands with no review for its landed revision is escalated over `review_missing` and is not verified without a human.
|
|
668
|
+
|
|
666
669
|
## Witness protocol (STH)
|
|
667
670
|
|
|
668
671
|
Every authenticated request echoes the caller's last-known Signed Tree Head (STH)
|
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,
|
|
@@ -1371,12 +1372,15 @@ async function reportStory({ story_id, artifact_type, artifact_path, token_usage
|
|
|
1371
1372
|
return toContent(result);
|
|
1372
1373
|
}
|
|
1373
1374
|
|
|
1374
|
-
async function reviewComplete({ story_id, review_type, findings_count, fixes_count, disproved_count, summary, claim }) {
|
|
1375
|
+
async function reviewComplete({ story_id, review_type, findings_count, fixes_count, disproved_count, summary, commit_sha, claim }) {
|
|
1375
1376
|
const body = { review_type };
|
|
1376
1377
|
if (findings_count != null) body.findings_count = findings_count;
|
|
1377
1378
|
if (fixes_count != null) body.fixes_count = fixes_count;
|
|
1378
1379
|
if (disproved_count != null) body.disproved_count = disproved_count;
|
|
1379
1380
|
if (summary) body.summary = summary;
|
|
1381
|
+
// loopctl US-47.5: the reviewed chg revision. Forwarded as given and judged server-side
|
|
1382
|
+
// (422 commit_sha_not_bound), so a malformed value reaches the one place that can say why.
|
|
1383
|
+
if (commit_sha != null) body.commit_sha = commit_sha;
|
|
1380
1384
|
if (claim) body.claim = claim;
|
|
1381
1385
|
|
|
1382
1386
|
const result = await apiCall(
|
|
@@ -1399,7 +1403,7 @@ async function verifyStory({ story_id, summary, review_type, claim }) {
|
|
|
1399
1403
|
if (review_type) body.review_type = review_type;
|
|
1400
1404
|
if (claim) body.claim = claim;
|
|
1401
1405
|
|
|
1402
|
-
// `exact_role: :orchestrator` (`story_verification_controller.ex:
|
|
1406
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:30-31`). When
|
|
1403
1407
|
// LOOPCTL_ORCH_KEY is set it is sent EXACTLY, because `resolveKey` would otherwise
|
|
1404
1408
|
// discard it in favour of a global LOOPCTL_API_KEY on the very calls it was set for.
|
|
1405
1409
|
// When it is NOT set, the global key still goes — see lib/custody-key.js for why the
|
|
@@ -1417,7 +1421,7 @@ async function verifyStory({ story_id, summary, review_type, claim }) {
|
|
|
1417
1421
|
}
|
|
1418
1422
|
|
|
1419
1423
|
async function rejectStory({ story_id, reason }) {
|
|
1420
|
-
// `exact_role: :orchestrator` (`story_verification_controller.ex:
|
|
1424
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:30-31`) — see
|
|
1421
1425
|
// lib/custody-key.js.
|
|
1422
1426
|
const orch = orchestratorKeyArgs();
|
|
1423
1427
|
|
|
@@ -1435,7 +1439,7 @@ async function rejectStory({ story_id, reason }) {
|
|
|
1435
1439
|
|
|
1436
1440
|
async function bulkMarkComplete({ stories }) {
|
|
1437
1441
|
// stories: [{story_id, summary, review_type}]
|
|
1438
|
-
// `exact_role: :orchestrator` (`bulk_operations_controller.ex:
|
|
1442
|
+
// `exact_role: :orchestrator` (`bulk_operations_controller.ex:25-26`) — see
|
|
1439
1443
|
// lib/custody-key.js.
|
|
1440
1444
|
const orch = orchestratorKeyArgs();
|
|
1441
1445
|
|
|
@@ -1450,7 +1454,7 @@ async function bulkMarkComplete({ stories }) {
|
|
|
1450
1454
|
}
|
|
1451
1455
|
|
|
1452
1456
|
async function verifyAllInEpic({ epic_id, review_type, summary }) {
|
|
1453
|
-
// `exact_role: :orchestrator` (`story_verification_controller.ex:
|
|
1457
|
+
// `exact_role: :orchestrator` (`story_verification_controller.ex:30-31`, `:verify_all`) —
|
|
1454
1458
|
// see lib/custody-key.js.
|
|
1455
1459
|
const orch = orchestratorKeyArgs();
|
|
1456
1460
|
|
|
@@ -3432,12 +3436,28 @@ async function storyStage(args) {
|
|
|
3432
3436
|
);
|
|
3433
3437
|
}
|
|
3434
3438
|
|
|
3439
|
+
// US-47.7: a READ, `role: :agent` with the hierarchy, so any role's key clears the gate and
|
|
3440
|
+
// nothing is pinned. LOOPCTL_AGENT_KEY is passed as this tool's own key for the reason
|
|
3441
|
+
// storyStage gives above — `resolveKey` never reads it by name, and a session on an
|
|
3442
|
+
// agent-key-only config is exactly the one asking which change its story is bound to. It is an
|
|
3443
|
+
// OVERRIDE, not `exactKey`, so `resolveKey`'s order holds: LOOPCTL_API_KEY when set, else
|
|
3444
|
+
// LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY.
|
|
3445
|
+
async function changeBindingGet(args) {
|
|
3446
|
+
const key = process.env.LOOPCTL_AGENT_KEY;
|
|
3447
|
+
|
|
3448
|
+
return toContent(
|
|
3449
|
+
await changeBindingGetRequest(args, {
|
|
3450
|
+
apiCall: (method, path, body) => apiCall(method, path, body, key),
|
|
3451
|
+
}),
|
|
3452
|
+
);
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3435
3455
|
async function resolveEscalation(args) {
|
|
3436
3456
|
return toContent(await resolveEscalationRequest(args, deliveryDeps()));
|
|
3437
3457
|
}
|
|
3438
3458
|
|
|
3439
3459
|
// #846: force-unclaim is `exact_role: :orchestrator`
|
|
3440
|
-
// (`story_verification_controller.ex:
|
|
3460
|
+
// (`story_verification_controller.ex:30-31`), so this is the one delivery-loop verb that wants
|
|
3441
3461
|
// the ORCH key. Same selection as the other four custody verbs: when LOOPCTL_ORCH_KEY is set it
|
|
3442
3462
|
// is sent EXACTLY, because `resolveKey` (`index.js:132-138`) would otherwise discard it for a
|
|
3443
3463
|
// global LOOPCTL_API_KEY on the one call it was set for; when it is not set, a global key still
|
|
@@ -4708,7 +4728,9 @@ const TOOLS = [
|
|
|
4708
4728
|
"reads pending or contracted — move on. An escalated story is claimable again only " +
|
|
4709
4729
|
"after resolve_escalation sends it to queued; a done or failed one never is. Refused " +
|
|
4710
4730
|
"409 dependencies_not_met when a story it depends on, or one in an epic its epic " +
|
|
4711
|
-
"depends on, is not verified: list_blocked_stories names them."
|
|
4731
|
+
"depends on, is not verified: list_blocked_stories names them. Refused 409 " +
|
|
4732
|
+
"chg_root_review_only when the key descends from an intake source's " +
|
|
4733
|
+
"chg_root_dispatch_id: chg-server's keys may only review.",
|
|
4712
4734
|
inputSchema: {
|
|
4713
4735
|
type: "object",
|
|
4714
4736
|
properties: {
|
|
@@ -4854,7 +4876,9 @@ const TOOLS = [
|
|
|
4854
4876
|
description:
|
|
4855
4877
|
"Reviewer (a DIFFERENT agent from the implementer) confirms the implementation is done. " +
|
|
4856
4878
|
"Chain-of-custody: the implementing agent cannot call this. " +
|
|
4857
|
-
"Transitions implementing -> reported_done. Uses the ORCH key."
|
|
4879
|
+
"Transitions implementing -> reported_done. Uses the ORCH key. Refused 409 " +
|
|
4880
|
+
"chg_root_review_only when the key descends from an intake source's " +
|
|
4881
|
+
"chg_root_dispatch_id: chg-server's keys may only review.",
|
|
4858
4882
|
inputSchema: {
|
|
4859
4883
|
type: "object",
|
|
4860
4884
|
properties: {
|
|
@@ -4889,7 +4913,16 @@ const TOOLS = [
|
|
|
4889
4913
|
name: "review_complete",
|
|
4890
4914
|
description:
|
|
4891
4915
|
"Record that a review has been completed for a story. " +
|
|
4892
|
-
"Must be called before verify_story. Uses the ORCH key."
|
|
4916
|
+
"Must be called before verify_story. Uses the ORCH key. " +
|
|
4917
|
+
"On a story BOUND TO A chg CHANGE (loopctl US-47.5) three more rules apply, after the " +
|
|
4918
|
+
"existing custody gates (self_review_blocked, caller_lineage_required, " +
|
|
4919
|
+
"unresolvable_dispatch_lineage), never instead of them: the key must be one chg-server " +
|
|
4920
|
+
"minted for the review — a strict descendant of the intake source's " +
|
|
4921
|
+
"chg_root_dispatch_id — else 409 chg_reviewer_required (chg-server's root key is " +
|
|
4922
|
+
"refused too); commit_sha is REQUIRED and must be the sha of a submitted revision of " +
|
|
4923
|
+
"the story's current change, else 422 commit_sha_not_bound; and one reviewer dispatch " +
|
|
4924
|
+
"records one verdict, a second is 409 chg_review_already_recorded. On any other story " +
|
|
4925
|
+
"commit_sha is ignored and not stored.",
|
|
4893
4926
|
inputSchema: {
|
|
4894
4927
|
type: "object",
|
|
4895
4928
|
properties: {
|
|
@@ -4918,6 +4951,13 @@ const TOOLS = [
|
|
|
4918
4951
|
type: "string",
|
|
4919
4952
|
description: "Optional: summary of the review outcome.",
|
|
4920
4953
|
},
|
|
4954
|
+
commit_sha: {
|
|
4955
|
+
type: "string",
|
|
4956
|
+
description:
|
|
4957
|
+
"REQUIRED on a story bound to a chg change: the reviewed revision commit, 40 or " +
|
|
4958
|
+
"64 lowercase hex characters, the sha of a submitted revision of the story's " +
|
|
4959
|
+
"current change (422 commit_sha_not_bound otherwise). Ignored on any other story.",
|
|
4960
|
+
},
|
|
4921
4961
|
claim: CLAIM_SCHEMA,
|
|
4922
4962
|
},
|
|
4923
4963
|
required: ["story_id", "review_type"],
|
|
@@ -4930,7 +4970,15 @@ const TOOLS = [
|
|
|
4930
4970
|
description:
|
|
4931
4971
|
"Orchestrator verifies a reported_done story. " +
|
|
4932
4972
|
"Requires a review_record to exist (call review_complete first). " +
|
|
4933
|
-
"Transitions reported_done -> verified. Uses the ORCH key."
|
|
4973
|
+
"Transitions reported_done -> verified. Uses the ORCH key. Refused 409 " +
|
|
4974
|
+
"chg_root_cannot_verify when the key descends from an intake source's " +
|
|
4975
|
+
"chg_root_dispatch_id (loopctl US-47.5): chg-server's keys review and never verify — " +
|
|
4976
|
+
"use an orchestrator key outside chg's tree. A story BOUND TO A chg CHANGE is verified " +
|
|
4977
|
+
"only on a chg review of its LANDED revision (the revision commit of the newest " +
|
|
4978
|
+
"applied landed event of its current change), else 409 " +
|
|
4979
|
+
"chg_review_not_for_landed_revision — have chg dispatch a review naming that sha; and " +
|
|
4980
|
+
"never while its stage is escalated over review_missing, else 409 " +
|
|
4981
|
+
"review_missing_escalated — a human resolves it first (resolve_escalation).",
|
|
4934
4982
|
inputSchema: {
|
|
4935
4983
|
type: "object",
|
|
4936
4984
|
properties: {
|
|
@@ -4959,7 +5007,10 @@ const TOOLS = [
|
|
|
4959
5007
|
"The reject releases the claim, and a delivery story may be re-contracted or escalated " +
|
|
4960
5008
|
"in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain " +
|
|
4961
5009
|
"entry was refused) means the WHOLE call rolled back: the story is unchanged, and a " +
|
|
4962
|
-
"re-run meets the same server-side condition until an operator acts."
|
|
5010
|
+
"re-run meets the same server-side condition until an operator acts. Refused 409 " +
|
|
5011
|
+
"chg_root_cannot_verify when the key descends from an intake source's " +
|
|
5012
|
+
"chg_root_dispatch_id (loopctl US-47.5): reject is a custody decision chg-server's " +
|
|
5013
|
+
"keys never make.",
|
|
4963
5014
|
inputSchema: {
|
|
4964
5015
|
type: "object",
|
|
4965
5016
|
properties: {
|
|
@@ -4982,7 +5033,9 @@ const TOOLS = [
|
|
|
4982
5033
|
description:
|
|
4983
5034
|
"Bulk mark multiple stories as complete (backfill-only for never-dispatched work). " +
|
|
4984
5035
|
"ADMIN USE ONLY: backfill pre-existing completed work into pending stories that never entered the dispatch lifecycle. " +
|
|
4985
|
-
"For dispatched stories, use the normal report/review/verify flow. Each story entry needs a story_id, summary, and review_type. Uses the ORCH key."
|
|
5036
|
+
"For dispatched stories, use the normal report/review/verify flow. Each story entry needs a story_id, summary, and review_type. Uses the ORCH key. " +
|
|
5037
|
+
"Refused 409 chg_root_cannot_verify for the whole batch when the key descends from an intake source's chg_root_dispatch_id (loopctl US-47.5). " +
|
|
5038
|
+
"A story bound to a chg change is a per-story error leading with review_missing_escalated or chg_review_not_for_landed_revision, the same landed-review gate verify_story applies.",
|
|
4986
5039
|
inputSchema: {
|
|
4987
5040
|
type: "object",
|
|
4988
5041
|
properties: {
|
|
@@ -5016,7 +5069,11 @@ const TOOLS = [
|
|
|
5016
5069
|
name: "verify_all_in_epic",
|
|
5017
5070
|
description:
|
|
5018
5071
|
"Bulk verify all reported_done, unverified stories in an epic. " +
|
|
5019
|
-
"Convenience endpoint for the orchestrator after a review pass. Uses the ORCH key."
|
|
5072
|
+
"Convenience endpoint for the orchestrator after a review pass. Uses the ORCH key. " +
|
|
5073
|
+
"Refused 409 chg_root_cannot_verify for the whole epic, verifying nothing, when the key " +
|
|
5074
|
+
"descends from an intake source's chg_root_dispatch_id (loopctl US-47.5). A story bound " +
|
|
5075
|
+
"to a chg change is skipped into errors with review_missing_escalated or " +
|
|
5076
|
+
"chg_review_not_for_landed_revision, the same landed-review gate verify_story applies.",
|
|
5020
5077
|
inputSchema: {
|
|
5021
5078
|
type: "object",
|
|
5022
5079
|
properties: {
|
|
@@ -8614,6 +8671,34 @@ const TOOLS = [
|
|
|
8614
8671
|
required: ["story_id"],
|
|
8615
8672
|
},
|
|
8616
8673
|
},
|
|
8674
|
+
{
|
|
8675
|
+
name: "change_binding_get",
|
|
8676
|
+
description:
|
|
8677
|
+
"THE CHG CHANGES BOUND TO A STORY (GET /api/v1/stories/:id/changes, loopctl US-47.7): " +
|
|
8678
|
+
"every change a `submitted` chg event bound to the story, OLDEST FIRST so the current " +
|
|
8679
|
+
"one is last, each with `repo`, `change_id`, `current`, `superseded_at`, " +
|
|
8680
|
+
"`submitter_not_claimant` and `inserted_at`, plus that change's APPLIED events in " +
|
|
8681
|
+
"`event_seq` order (`event_seq`, `kind`, `revision`, `sha`, `occurred_at`, " +
|
|
8682
|
+
"`applied_at`). An event held ahead of a gap is not listed, and the event `data` " +
|
|
8683
|
+
"payload is never returned.\n\n" +
|
|
8684
|
+
"BOUNDED by the server, and the bounds are in `meta`: at most `max_bindings` bindings " +
|
|
8685
|
+
"and `max_events_per_change` events per change, the NEWEST of each, so the current " +
|
|
8686
|
+
"binding is always listed. `binding_count` and each binding's `applied_event_count` / " +
|
|
8687
|
+
"`events_truncated` say when more exist.\n\n" +
|
|
8688
|
+
"Any key of the tenant may read it (`role: :agent` with the hierarchy), so the key is " +
|
|
8689
|
+
"the usual order with LOOPCTL_AGENT_KEY as this tool's own: LOOPCTL_API_KEY when set, " +
|
|
8690
|
+
"else LOOPCTL_AGENT_KEY, else LOOPCTL_ORCH_KEY. REFUSES: 404 when the story " +
|
|
8691
|
+
"is not your tenant's — another tenant's id answers exactly like an unknown one; 401 " +
|
|
8692
|
+
"for no key or one that does not authenticate; 400 for a superadmin key without " +
|
|
8693
|
+
"X-Impersonate-Tenant; 429 when rate limited. No 403: every key role clears the " +
|
|
8694
|
+
"`role: :agent` gate, so none is refused for its role. A malformed " +
|
|
8695
|
+
"`story_id` is refused locally (status 0, nothing sent).",
|
|
8696
|
+
inputSchema: {
|
|
8697
|
+
type: "object",
|
|
8698
|
+
properties: { story_id: { type: "string", description: "The story UUID." } },
|
|
8699
|
+
required: ["story_id"],
|
|
8700
|
+
},
|
|
8701
|
+
},
|
|
8617
8702
|
{
|
|
8618
8703
|
name: "resolve_escalation",
|
|
8619
8704
|
description:
|
|
@@ -10145,6 +10230,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
10145
10230
|
case "story_stage":
|
|
10146
10231
|
return await storyStage(args);
|
|
10147
10232
|
|
|
10233
|
+
case "change_binding_get":
|
|
10234
|
+
return await changeBindingGet(args);
|
|
10235
|
+
|
|
10148
10236
|
case "resolve_escalation":
|
|
10149
10237
|
return await resolveEscalation(args);
|
|
10150
10238
|
|
|
@@ -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
|
+
}
|
package/lib/custody-key.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Key selection for the `exact_role: :orchestrator` custody verbs (loopctl #846 review round 2).
|
|
3
3
|
*
|
|
4
4
|
* WHICH VERBS. `verify`, `reject` and `force_unclaim` plus the aggregate `verify_all`
|
|
5
|
-
* (`lib/loopctl_web/controllers/story_verification_controller.ex:
|
|
5
|
+
* (`lib/loopctl_web/controllers/story_verification_controller.ex:30-31`), and bulk
|
|
6
6
|
* `verify`/`reject`/`mark_complete`
|
|
7
|
-
* (`lib/loopctl_web/controllers/bulk_operations_controller.ex:
|
|
7
|
+
* (`lib/loopctl_web/controllers/bulk_operations_controller.ex:25-26`). All are mounted
|
|
8
8
|
* `exact_role: :orchestrator`, where the role hierarchy does NOT apply: `RequireRole`'s
|
|
9
9
|
* exact-role clause tests `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user`
|
|
10
10
|
* or `:superadmin` key is refused there exactly as an `:agent` key is.
|
package/lib/delivery-loop.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* - `resolve_escalation` is the HUMAN half of the escalation pair: a `:user` key no dispatch
|
|
22
22
|
* minted. The agent key that raises an escalation is 403'd on it by design.
|
|
23
23
|
* - `force_unclaim_story` is gated `exact_role: :orchestrator`
|
|
24
|
-
* (`story_verification_controller.ex:
|
|
24
|
+
* (`story_verification_controller.ex:30-31`), so it needs an ORCHESTRATOR-ROLE key and a
|
|
25
25
|
* higher-privileged one does NOT substitute: `RequireRole`'s exact-role clause tests
|
|
26
26
|
* `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user` or `:superadmin`
|
|
27
27
|
* key is 403'd there exactly as an agent key is. That is a chain-of-custody gate, not an
|
|
@@ -293,7 +293,7 @@ export async function resolveEscalation({ story_id, to, reason } = {}, { userKey
|
|
|
293
293
|
* No request body — the story is named in the path.
|
|
294
294
|
*
|
|
295
295
|
* ORCHESTRATOR ROLE, and exactly that: the action is `exact_role: :orchestrator`
|
|
296
|
-
* (`story_verification_controller.ex:
|
|
296
|
+
* (`story_verification_controller.ex:30-31`), so a user or superadmin key is refused —
|
|
297
297
|
* `RequireRole`'s exact-role clause tests `api_key.role == exact_role`
|
|
298
298
|
* (`require_role.ex:65-75`).
|
|
299
299
|
*
|