loopctl-mcp-server 2.108.0 → 2.111.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
@@ -201,7 +201,7 @@ tool parameter.
201
201
  | `create_project` | Create a new **work** project (epics/stories/custody). Requires orchestrator+ **and** a `human_anchored` tenant; an agent-rooted tenant gets `403 custody_tier_required` and should use `create_kb_scope`. |
202
202
  | `delete_project` | **Requires `LOOPCTL_USER_KEY`.** Delete a project and all of its dependent resources (epics, stories, audit entries). Irreversible — orchestrator role is not sufficient. |
203
203
  | `get_progress` | Get progress summary for a project, including story counts by status. Pass `include_cost=true` for cost data. |
204
- | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). For large payloads, use `payload_path` to read JSON from disk instead of passing it inline. |
204
+ | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). For large payloads, use `payload_path` to read JSON from disk instead of passing it inline. With `merge: true`, `report_orphans: true` adds `stories_orphaned` (the project's stories the payload does not mention; nothing is detached) — a signal only on a FULL round-trip of an export; without it the key is absent (#880). |
205
205
 
206
206
  ### KB Scope Tools (agent key)
207
207
 
@@ -243,6 +243,13 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
243
243
  | `get_story` | Get full details for a single story by ID. |
244
244
  | `create_story` | Create a single story inside an existing epic. Use instead of wrapping a story in a bulk import. Accepts either `epic_id` (UUID) or (`project_id` + `epic_number`). |
245
245
  | `update_story` | **Correct a filed story** (`PATCH /api/v1/stories/:id`) — its `title`, `description`, `acceptance_criteria`, `estimated_hours` or `metadata`, and nothing else. `number` is fixed at creation and the two status fields are custody state with their own gated endpoints. **Sending `metadata` REPLACES THE WHOLE MAP** — there is no merge on this path, so a partial send silently drops every key you left out and answers 200. Read the story first (`get_story`), then send the complete map; `acceptance_criteria` is replaced whole for the same reason. The one key it can no longer erase is `lifecycle_entered_at`, which is a COLUMN precisely because it used to live in metadata and one ordinary PATCH erased it, restoring the claim -> force-unclaim -> backfill-to-verified launder. A field cannot be set to `null` either: the controller drops every nil before the changeset, so `null` is refused here rather than answering 200 unchanged, and so is an `estimated_hours` no decimal parser accepts. Requires orchestrator role or higher (`LOOPCTL_ORCH_KEY`, or `LOOPCTL_API_KEY` of that role) on a human-anchored tenant. |
246
+ | `list_epics` | **List a project's epics** (`GET /api/v1/projects/:project_id/epics`, loopctl #876): each epic's `id`, `number`, `title`, `description`, `phase`, `position`, `metadata`, `story_count` and `completion_percentage`, paginated, optionally filtered by `phase`. The way to learn an epic id, e.g. an intake source's `target_epic_id`. Any key. |
247
+ | `create_epic` | **Create an epic** in a work project (`POST /api/v1/projects/:project_id/epics`) and return it with its `id`. `number` (fixed for good) and `title` required; `description`, `phase`, `position`, `metadata` optional. `LOOPCTL_ORCH_KEY`, or `LOOPCTL_USER_KEY` (orchestrator or above; sent verbatim when set) on a human-anchored tenant; 403 otherwise, 404 for a project outside your tenant, 422 for a kb scope or an invalid field. |
248
+ | `get_epic` | **Read one epic** with its stories (`GET /api/v1/epics/:id`). Any key; 404 outside your tenant. |
249
+ | `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. |
250
+ | `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). |
251
+ | `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); its change-thread ledger outlives it. |
252
+ | `epic_progress` | **An epic's progress** (`GET /api/v1/epics/:id/progress`): its stories counted by `agent_status` and `verified_status`. Any key. |
246
253
  | `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. |
247
254
  | `get_acceptance_criteria` | List a story's acceptance criteria with each one's verification status. Required: `story_id`. |
248
255
 
@@ -520,9 +527,10 @@ The order to wire it up, the stage machine these tools move a story through, and
520
527
  | `thread_get` | **Read a story's change thread** (`GET /api/v1/stories/:id/thread`, any role): its latest page of checkpoints (`checkpoints_truncated` is true when older ones exist), and its entries in `seq` order, paged with `after_seq` / `limit` (the response's `next_after_seq` is the next page's `after_seq`). Every entry body is untrusted text (`body_untrusted: true`). |
521
528
  | `thread_checkpoint` | **Record a checkpoint on the story you hold** (`POST /api/v1/stories/:id/thread/checkpoints`): a pushed commit plus your reasoning in `note`, sent on the key `claim_story` claims with (`LOOPCTL_API_KEY` when set, else `LOOPCTL_AGENT_KEY`). Refusals: 409 `not_claimant`; 409 `stale_claim_epoch` or `claim_not_live` (your claim no longer accepts work: it ended, its lease lapsed, you requested review or reported it); 409 `checkpoint_conflict` (recorded under your claim with another tree or note); 422 for a malformed sha, a commit and tree of different object formats, an empty or oversized note (bytes), or a note carrying a credential (`secret_blocked`). Idempotent on (`commit_sha`, `claim_epoch`). Required: `story_id`, `claim_epoch`, `commit_sha`, `tree_sha`. |
522
529
  | `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. `principal` picks the key: agent (default, the key `claim_story` uses), orchestrator or user. |
523
- | `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`, `implementer_dispatch_required`, `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`. |
530
+ | `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`. |
524
531
  | `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. Bodies and locations are untrusted. |
525
532
  | `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`. |
533
+ | `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`. |
526
534
  | `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). |
527
535
  | `merge_precondition` | **Run the merge gate over a story's real pull request** (`POST /api/v1/stories/:id/merge-precondition`). Both delivery gates run again over the diff that exists, plus custody, the 12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy exclusion; the story must be at stage `ci`. **Gate A reads what triage persisted, never the caller**: the lens verdicts recorded with the story's most recent triage, or a human's re-queue of a Gate A escalation — `gate_a_inputs` on the answer says which, and `missing` refuses with `gate_a_inputs_missing`. This tool sends no trio. Decisions: `allow` (recorded against the head; the only thing that licenses a merge), `refuse` (the story is escalated before this returns), `already_merged`, `head_moved`, `unevaluated` (503 with `Retry-After`). **Thread mode** (the current claim was placed under a source with `mode: thread` — the mode is bound to the dispatch at placement): no pull request; the gate judges the latest checkpoint recorded under the story's CURRENT claim, on the branch that claim's dispatch ran on, and adds refusals `empty_change`, `checkpoint_tree_mismatch`, `no_checkpoint_recorded` and `claim_ended` (the claim was released after an earlier one recorded checkpoints); the source's runners must be at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; the branch is judged first, and a missing branch in a readable repository (`branch_missing`), a head nobody recorded (`branch_head_unrecorded`), an earlier checkpoint (`branch_head_regressed`), or a head the stage row did not record is `head_moved` back to implementing while the claim is LIVE, and a refusal naming `claim_not_live` (escalated) when it is not; an unreadable repository refuses `pull_request_unavailable`; the diff judged is the three-dot diff against the merge base, so a base that moved on is not a refusal, and a checkpoint the base already contains (its branch deleted or not) is `already_merged` only under a recorded allow naming it, otherwise `checkpoint_on_base_without_allow`; a thread loopctl could not read refuses `thread_unreadable`; CI is read by the checkpoint's exact SHA from both the check-runs and the commit-status APIs (only a job of a GitHub Actions workflow run that a push of the thread branch at that commit triggered satisfies a required check, and only by concluding `success` — a skipped job fails; statuses and other check runs are recorded or ignored, never trusted) against the source's `required_checks` — a failed one refuses `required_check_failed`, one still running or not yet reported is `unevaluated` (`required_check_pending` / `required_check_missing`, retry after the answer's `Retry-After`; neither counts toward the unevaluated bound, and past the gate's CI wait limit from the story's entry into `ci` both are refused `required_check_timed_out`; per name the newest run of each workflow counts and every workflow must pass; a checkpoint changing `.github/workflows/` or `.github/actions/` is refused `ci_definition_changed`), a source requiring none refuses `required_checks_unset`, a failed read is `ci_evidence_unavailable`, and `local-gate` is recorded but never counted; the answer's `ci_evidence` is what was read and is copied onto the checkpoint; the answer carries `mode` (bound at placement, `pr` when the claim has no accepted dispatch whatever the source says now, null only on ledger contention) and `base_sha` (that merge base), an allow records it, and the merge executor merges only while the base head still equals it. Requires an orchestrator- or user-ROLE key (`exact_role: [:orchestrator, :user]`, so an agent key is 403'd); `LOOPCTL_ORCH_KEY` is sent when set, else `LOOPCTL_API_KEY`. 403 `custody_tier_required` without a human anchor, 404 for an unknown story, 422 when the story is not at `ci`. Required: `story_id` (refused locally if not a UUID) and `claim_epoch` (a non-negative integer, refused locally otherwise); optional `effect_proof`. |
528
536
  | `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. `mode` picks the merge route: `pr` (the default when omitted) or `thread`, where the merge gate evaluates the story's latest recorded checkpoint instead of a pull request — which needs the source's runners at contract 1.20.0+ sending checkpoint messages, or every story is refused `no_checkpoint_recorded`; any other value, null included, is refused. The mode is bound to each implement dispatch when it is placed, so it decides only what stories placed afterwards get; a change is always allowed. A `thread` source must also name `required_checks` (422 otherwise): the CI checks the merge gate requires on a checkpoint's exact commit, GitHub Actions check-run names, bounded in number and length by the server; `local-gate` is refused because whoever pushed posts it. 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`. |
package/index.js CHANGED
@@ -32,6 +32,9 @@ import {
32
32
  buildCorpusCreateBody,
33
33
  buildCorpusIndexBody,
34
34
  buildCorpusSearchBody,
35
+ importPath,
36
+ importPayloadRefusal,
37
+ importRefusal,
35
38
  } from "./lib/http-helpers.js";
36
39
  import {
37
40
  createWitnessClient,
@@ -53,6 +56,7 @@ import {
53
56
  recordCheckpoint as recordCheckpointRequest,
54
57
  recordEntry as recordEntryRequest,
55
58
  recordFix as recordFixRequest,
59
+ reportStage as reportStageRequest,
56
60
  requestReview as requestReviewRequest,
57
61
  } from "./lib/threads.js";
58
62
  import {
@@ -62,7 +66,10 @@ import {
62
66
  resolveEscalation as resolveEscalationRequest,
63
67
  storyStage as storyStageRequest,
64
68
  } from "./lib/delivery-loop.js";
65
- import { updateStory as updateStoryRequest } from "./lib/story-update.js";
69
+ import {
70
+ deleteStory as deleteStoryRequest,
71
+ updateStory as updateStoryRequest,
72
+ } from "./lib/story-update.js";
66
73
  import {
67
74
  enrollIntakeSource,
68
75
  listIntakeSources,
@@ -71,6 +78,16 @@ import {
71
78
  } from "./lib/intake-sources.js";
72
79
  import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
73
80
  import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
81
+ import {
82
+ createEpic as createEpicRequest,
83
+ deleteEpic as deleteEpicRequest,
84
+ deleteKeyHint,
85
+ epicWriteKeyHint,
86
+ epicProgress as epicProgressRequest,
87
+ getEpic as getEpicRequest,
88
+ listEpics as listEpicsRequest,
89
+ updateEpic as updateEpicRequest,
90
+ } from "./lib/epics.js";
74
91
 
75
92
  // Single source of truth for the server version: the package.json this file
76
93
  // ships with (npm always includes package.json in the published tarball).
@@ -189,7 +206,10 @@ async function apiCall(
189
206
  // `requestAuthenticatorRevokeChallenge`, `revokeAuthenticator`), and the shared
190
207
  // `runnerDeps()` / `deliveryDeps()` factories — the latter covering
191
208
  // `place_dispatch` and `resolve_escalation`, which need an unlineaged human
192
- // principal. `grep -n 'exactKey: true' index.js` is the current list. None of
209
+ // principal. `grep -n 'exactKey: true' index.js` is the current list, and it also
210
+ // finds `pinnedApiCall`, the epic and story writes' CONDITIONAL pin, which falls back
211
+ // to the default key when its variable is unset and is described where it is defined.
212
+ // None of the unconditional family
193
213
  // them may silently run under a global key of another role (review #12).
194
214
  // - The `exact_role: :orchestrator` CUSTODY verbs pass it CONDITIONALLY, and
195
215
  // nothing here decides that: `orchestratorKeyArgs` (`lib/custody-key.js`)
@@ -1008,15 +1028,25 @@ async function createStory({ project_id, epic_number, epic_id, story }) {
1008
1028
  return toContent(result);
1009
1029
  }
1010
1030
 
1011
- async function importStories({ project_id, payload, payload_path, merge }) {
1031
+ async function importStories({ project_id, payload, payload_path, merge, report_orphans }) {
1032
+ // The cheap argument check first, before a payload file is read (#880).
1033
+ const refused = importRefusal({ merge, report_orphans });
1034
+ if (refused) return toContent({ error: true, status: 0, body: refused });
1035
+
1012
1036
  const effectivePayload = await resolvePayload(payload, payload_path);
1013
1037
  if (effectivePayload && effectivePayload.error) {
1014
1038
  return toContent(effectivePayload);
1015
1039
  }
1016
- const query = merge ? "?merge=true" : "";
1040
+
1041
+ // The payload is the request BODY, and the server reads flags there too: a payload carrying
1042
+ // its own would override or contradict the arguments, so it is refused, not sent.
1043
+ const payloadRefused = importPayloadRefusal(effectivePayload);
1044
+ if (payloadRefused) return toContent({ error: true, status: 0, body: payloadRefused });
1045
+
1046
+ // `report_orphans` asks a merge for `stories_orphaned` (#880); without it the key is absent.
1017
1047
  const result = await apiCall(
1018
1048
  "POST",
1019
- `/api/v1/projects/${project_id}/import${query}`,
1049
+ importPath(project_id, { merge, report_orphans }),
1020
1050
  effectivePayload,
1021
1051
  process.env.LOOPCTL_ORCH_KEY
1022
1052
  );
@@ -1085,6 +1115,61 @@ async function getStory({ story_id }) {
1085
1115
  return toContent(result);
1086
1116
  }
1087
1117
 
1118
+ // --- Epics (loopctl #876) ---
1119
+ // Reads travel on the default key (any role passes). Create and update go through
1120
+ // `orchestratorPinnedApiCall`: the named orchestrator or user key VERBATIM, so a global
1121
+ // LOOPCTL_API_KEY of a lesser role cannot displace it, and the global key when neither is set. The deletes go through `userKeyApiCall`, pinned to LOOPCTL_USER_KEY so a
1122
+ // global LOOPCTL_API_KEY cannot displace it: they destroy rows. (`delete_project` is not
1123
+ // pinned this way.)
1124
+
1125
+ // ONE pinning rule for the epic and story writes: the env var `hint()` names is sent VERBATIM
1126
+ // when it is set, so a global LOOPCTL_API_KEY of a lesser role cannot displace it, and with none
1127
+ // set the default selection (`resolveKey`) applies. Unlike the unconditional user-key family
1128
+ // described above `apiCall`, these fall back rather than refuse: their gates use the role
1129
+ // hierarchy (`role:`, never `exact_role:`), so a sufficient global key passes.
1130
+ function pinnedApiCall(hint) {
1131
+ return (method, path, body) => {
1132
+ const keyHint = hint();
1133
+
1134
+ return keyHint
1135
+ ? apiCall(method, path, body, process.env[keyHint], { exactKey: true, keyHint })
1136
+ : apiCall(method, path, body);
1137
+ };
1138
+ }
1139
+
1140
+ // Create and update: LOOPCTL_ORCH_KEY, else LOOPCTL_USER_KEY (`epicWriteKeyHint`).
1141
+ const orchestratorPinnedApiCall = pinnedApiCall(epicWriteKeyHint);
1142
+ // The deletes: LOOPCTL_USER_KEY (`deleteKeyHint`).
1143
+ const userKeyApiCall = pinnedApiCall(deleteKeyHint);
1144
+
1145
+ async function listEpics(args) {
1146
+ return toContent(await listEpicsRequest(args, { apiCall }));
1147
+ }
1148
+
1149
+ async function getEpic(args) {
1150
+ return toContent(await getEpicRequest(args, { apiCall }));
1151
+ }
1152
+
1153
+ async function epicProgress(args) {
1154
+ return toContent(await epicProgressRequest(args, { apiCall }));
1155
+ }
1156
+
1157
+ async function createEpic(args) {
1158
+ return toContent(await createEpicRequest(args, { apiCall: orchestratorPinnedApiCall }));
1159
+ }
1160
+
1161
+ async function updateEpic(args) {
1162
+ return toContent(await updateEpicRequest(args, { apiCall: orchestratorPinnedApiCall }));
1163
+ }
1164
+
1165
+ async function deleteEpic(args) {
1166
+ return toContent(await deleteEpicRequest(args, { apiCall: userKeyApiCall }));
1167
+ }
1168
+
1169
+ async function deleteStory(args) {
1170
+ return toContent(await deleteStoryRequest(args, { apiCall: userKeyApiCall }));
1171
+ }
1172
+
1088
1173
  // --- Workflow Tools (agent key) ---
1089
1174
 
1090
1175
  async function contractStory({ story_id, story_title, ac_count }) {
@@ -1257,6 +1342,10 @@ async function threadFix(args) {
1257
1342
  return toContent(await recordFixRequest(args, { apiCall: threadApiCall }));
1258
1343
  }
1259
1344
 
1345
+ async function threadStageReport(args) {
1346
+ return toContent(await reportStageRequest(args, { apiCall: threadApiCall }));
1347
+ }
1348
+
1260
1349
  async function startStory({ story_id, capability }) {
1261
1350
  // Cache first, then DELIVERY of the token claim already minted, then recovery
1262
1351
  // (which mints a fresh one). Delivery also covers a legacy env-var key, whose
@@ -4189,6 +4278,140 @@ const TOOLS = [
4189
4278
  required: ["story"],
4190
4279
  },
4191
4280
  },
4281
+ {
4282
+ name: "list_epics",
4283
+ description:
4284
+ "LIST A PROJECT'S EPICS (GET /api/v1/projects/:project_id/epics, loopctl #876): each " +
4285
+ "epic's `id`, `number`, `title`, `description`, `phase`, `position`, `metadata`, " +
4286
+ "`story_count` and `completion_percentage`, paginated (`page`, " +
4287
+ "`page_size`), optionally filtered by `phase`. The way to learn an epic's id, e.g. the " +
4288
+ "`target_epic_id` an intake source needs. Any key. Refusals: 404 for a project not in " +
4289
+ "your tenant; a malformed `project_id` is refused locally.",
4290
+ inputSchema: {
4291
+ type: "object",
4292
+ properties: {
4293
+ project_id: { type: "string", description: "The project's UUID." },
4294
+ page: { type: "integer", description: "Page number (default 1)." },
4295
+ page_size: { type: "integer", description: "Items per page." },
4296
+ phase: { type: "string", description: "Only epics in this phase." },
4297
+ },
4298
+ required: ["project_id"],
4299
+ },
4300
+ },
4301
+ {
4302
+ name: "create_epic",
4303
+ description:
4304
+ "CREATE AN EPIC in a work project (POST /api/v1/projects/:project_id/epics, loopctl " +
4305
+ "#876) and return it, `id` included. `number` (an integer, fixed for good) and `title` " +
4306
+ "are required; `description`, `phase`, `position` (default 0) and `metadata` are " +
4307
+ "optional. Needs an orchestrator-or-above key on a human-anchored tenant: " +
4308
+ "LOOPCTL_ORCH_KEY, else LOOPCTL_USER_KEY, sent verbatim, else the default key. " +
4309
+ "Refusals: 403 for an agent key or a tenant that is not human-anchored, 404 for a " +
4310
+ "project not in your tenant, 422 for a kb scope (epics live in work projects) or a " +
4311
+ "duplicate or invalid field.",
4312
+ inputSchema: {
4313
+ type: "object",
4314
+ properties: {
4315
+ project_id: { type: "string", description: "The work project's UUID." },
4316
+ number: {
4317
+ type: "integer",
4318
+ minimum: 1,
4319
+ description: "The epic's number, at least 1; cannot change later.",
4320
+ },
4321
+ title: { type: "string", description: "The epic's title." },
4322
+ description: { type: "string", description: "Optional description." },
4323
+ phase: { type: "string", description: "Optional phase." },
4324
+ position: { type: "integer", description: "Optional ordering position (default 0)." },
4325
+ metadata: { type: "object", description: "Optional metadata map." },
4326
+ },
4327
+ required: ["project_id", "number", "title"],
4328
+ },
4329
+ },
4330
+ {
4331
+ name: "get_epic",
4332
+ description:
4333
+ "READ ONE EPIC with its stories (GET /api/v1/epics/:id, loopctl #876). Any key. " +
4334
+ "Refusals: 404 for an epic not in your tenant; a malformed `epic_id` is refused locally.",
4335
+ inputSchema: {
4336
+ type: "object",
4337
+ properties: {
4338
+ epic_id: { type: "string", description: "The epic's UUID (from list_epics or create_epic)." },
4339
+ },
4340
+ required: ["epic_id"],
4341
+ },
4342
+ },
4343
+ {
4344
+ name: "update_epic",
4345
+ description:
4346
+ "CORRECT AN EPIC (PATCH /api/v1/epics/:id, loopctl #876): any of `title`, " +
4347
+ "`description`, `phase`, `position`, `metadata`. `number` cannot change. The endpoint " +
4348
+ "DROPS absent and null fields, so a null and a call naming no field are refused " +
4349
+ "locally; send an EMPTY STRING to clear `description` or `phase` (a blank `title` is a " +
4350
+ "422). `metadata` is REPLACED WHOLE, never merged: read the epic first " +
4351
+ "(get_epic) and send the whole map. Needs an orchestrator-or-above key on a " +
4352
+ "human-anchored tenant: LOOPCTL_ORCH_KEY, else LOOPCTL_USER_KEY, else the default key. Refusals: 403, 404, 422 for an invalid field.",
4353
+ inputSchema: {
4354
+ type: "object",
4355
+ properties: {
4356
+ epic_id: { type: "string", description: "The epic's UUID (from list_epics or create_epic)." },
4357
+ title: { type: "string" },
4358
+ description: { type: "string" },
4359
+ phase: { type: "string" },
4360
+ position: { type: "integer" },
4361
+ metadata: { type: "object", description: "Replaces the stored map whole." },
4362
+ },
4363
+ required: ["epic_id"],
4364
+ },
4365
+ },
4366
+ {
4367
+ name: "delete_epic",
4368
+ description:
4369
+ "DELETE AN EPIC AND EVERY STORY IN IT (DELETE /api/v1/epics/:id, loopctl #876). " +
4370
+ "IRREVERSIBLE: the delete cascades to the epic's stories. Answers 204 with no body. " +
4371
+ "Needs a user-or-above key on a human-anchored tenant: LOOPCTL_USER_KEY, sent verbatim " +
4372
+ "when set (a global LOOPCTL_API_KEY never displaces it), else the default key. " +
4373
+ "Refusals: 403 for a lesser key or a tenant that is not " +
4374
+ "human-anchored, 404 for an epic not in your tenant, 422 when an active intake source " +
4375
+ "targets the epic (revoke it first: intake_source_revoke), 422 when a dispatch, " +
4376
+ "capability token or verification run references any story in it (those do not " +
4377
+ "cascade). A story's change-thread ledger is NOT deleted with it: it outlives the story.",
4378
+ inputSchema: {
4379
+ type: "object",
4380
+ properties: {
4381
+ epic_id: { type: "string", description: "The epic's UUID (from list_epics or create_epic)." },
4382
+ },
4383
+ required: ["epic_id"],
4384
+ },
4385
+ },
4386
+ {
4387
+ name: "delete_story",
4388
+ description:
4389
+ "DELETE ONE STORY (DELETE /api/v1/stories/:id, loopctl #876). IRREVERSIBLE. Answers 204 " +
4390
+ "with no body. Needs a user-or-above key on a human-anchored tenant: LOOPCTL_USER_KEY " +
4391
+ "verbatim when set, else the default key. Refusals: 403 for a lesser key, 404 for a story not in your tenant, 422 when " +
4392
+ "a dispatch, capability token or verification run references the story (those do not " +
4393
+ "cascade). Its change-thread ledger outlives it. A malformed `story_id` is refused " +
4394
+ "locally.",
4395
+ inputSchema: {
4396
+ type: "object",
4397
+ properties: { story_id: { type: "string", description: "The story's UUID." } },
4398
+ required: ["story_id"],
4399
+ },
4400
+ },
4401
+ {
4402
+ name: "epic_progress",
4403
+ description:
4404
+ "AN EPIC'S PROGRESS (GET /api/v1/epics/:id/progress, loopctl #876): its stories " +
4405
+ "counted by `agent_status` and by `verified_status`. Any key. Refusals: 404 for an " +
4406
+ "epic not in your tenant; a malformed `epic_id` is refused locally.",
4407
+ inputSchema: {
4408
+ type: "object",
4409
+ properties: {
4410
+ epic_id: { type: "string", description: "The epic's UUID (from list_epics or create_epic)." },
4411
+ },
4412
+ required: ["epic_id"],
4413
+ },
4414
+ },
4192
4415
  {
4193
4416
  name: "update_story",
4194
4417
  description:
@@ -4257,6 +4480,9 @@ const TOOLS = [
4257
4480
  description:
4258
4481
  "Import stories into a project from a structured payload (Epic 12 import format). " +
4259
4482
  "Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). " +
4483
+ "A merge answers counts; `stories_orphaned` (the project's stories the payload does not " +
4484
+ "mention; nothing is detached) is in the response ONLY with `report_orphans: true`, " +
4485
+ "which is a signal only on a full round-trip of an export (#880). " +
4260
4486
  "For large payloads, use `payload_path` to read JSON from disk instead of passing it inline.",
4261
4487
  inputSchema: {
4262
4488
  type: "object",
@@ -4282,6 +4508,15 @@ const TOOLS = [
4282
4508
  "When false or omitted, duplicates return 409.",
4283
4509
  default: false,
4284
4510
  },
4511
+ report_orphans: {
4512
+ type: "boolean",
4513
+ description:
4514
+ "Merge only. When true the response carries `stories_orphaned`: the project's " +
4515
+ "stories this payload does not mention. Nothing is detached either way. It is a " +
4516
+ "signal only when the payload is a FULL round-trip of an export; on a partial " +
4517
+ "merge every unmentioned story is listed. When false or omitted the key is absent.",
4518
+ default: false,
4519
+ },
4285
4520
  },
4286
4521
  required: ["project_id"],
4287
4522
  },
@@ -4429,7 +4664,13 @@ const TOOLS = [
4429
4664
  name: "claim_story",
4430
4665
  description:
4431
4666
  "Agent claims a contracted story. Uses pessimistic locking to prevent double-claims. " +
4432
- "Transitions contracted -> assigned. Uses the AGENT key. On a loopctl with claim leases " +
4667
+ "Transitions contracted -> assigned. Uses the AGENT key. On a THREAD-mode repository " +
4668
+ "(its intake source's mode) a claim of a story queued by triage is an interactive " +
4669
+ "change thread (US-45.9): push checkpoints to the loop/ branch and record them with " +
4670
+ "thread_checkpoint, move the story's stage with thread_stage_report, get a runner " +
4671
+ "review with thread_request_review, and loopctl's App merges the checkpoint the gate " +
4672
+ "allows. The mode, base branch and branch are bound when you claim, and the result's " +
4673
+ "`route` names them: push your checkpoints to `route.branch`. On a loopctl with claim leases " +
4433
4674
  "the result leads with the claim's claim_epoch and claimed_until: keep the epoch, and " +
4434
4675
  "renew with renew_story_claim before claimed_until (default lease 24 hours) or the story " +
4435
4676
  "is released back to pending under you. Refused 409 story_held when the story's " +
@@ -4454,7 +4695,9 @@ const TOOLS = [
4454
4695
  description:
4455
4696
  "Renew your claim's lease (POST /api/v1/stories/:id/renew-claim): claimed_until becomes " +
4456
4697
  "now plus the lease length (default 24 hours), measured from NOW. Call it well inside the " +
4457
- "lease on any story you hold longer than it. A DRIVER-PLACED claim (one a placement took " +
4698
+ "lease on any story you hold longer than it. An interactive thread claim (US-45.9) has no " +
4699
+ "runner keeping it alive: when its lease lapses its checkpoints and stage reports are " +
4700
+ "refused and the thread ends with the claim. A DRIVER-PLACED claim (one a placement took " +
4458
4701
  "for a runner dispatch) is CAPPED AT ITS DISPATCH DEADLINE (claim_lease_cap: " +
4459
4702
  "placed_at + wall_clock_seconds + DISPATCH_LEASE_GRACE_SECONDS at the claim, which is " +
4460
4703
  "the dispatch's deadline_at; moved forward to a resume's time or the runner's " +
@@ -8281,6 +8524,50 @@ const TOOLS = [
8281
8524
  required: ["story_id"],
8282
8525
  },
8283
8526
  },
8527
+ {
8528
+ name: "thread_stage_report",
8529
+ description:
8530
+ "REPORT A STAGE TRANSITION of the story you hold, when you claimed it YOURSELF on a " +
8531
+ "thread-mode repository (POST /api/v1/stories/:id/stage/transitions, US-45.9). An " +
8532
+ "interactive claim of a thread-mode story is a change thread with no runner, so you " +
8533
+ "move its delivery stage the way a runner would: `claimed` -> `worktree` " +
8534
+ "-> `implementing` -> `reviewing` -> `pr_open` -> `ci`, passing `head_sha` (the " +
8535
+ "checkpoint you want merged) ONLY when you enter `ci`; `ci_red`, `review_findings` and " +
8536
+ "`base_moved` send it back to `implementing`. Never into `merged` and never " +
8537
+ "`merge_refused`: loopctl's App makes the merge and records it. After it, report " +
8538
+ "`merged` -> `deployed` with the `release_id` once the deploy lands. Your first report " +
8539
+ "(from `claimed`) enters the claim. Travels on the key claim_story claims " +
8540
+ "with. A resend of a report that already landed answers 200 with `replayed: true`. " +
8541
+ "Refusals: 404 (not your tenant's story); 409 `not_claimant`, `stale_claim_epoch`, " +
8542
+ "`claim_not_live` (your lease lapsed, renew it with renew_story_claim while you work, " +
8543
+ "or you requested review), `not_interactive_thread_claim` (a placed claim, whose runner " +
8544
+ "reports for it, or a pr-mode claim), `effect_conflict` (a different `head_sha` is already recorded; the body names it), " +
8545
+ "`stale_stage` (the story is elsewhere; the body " +
8546
+ "names its stage and recorded effects); 422 `invalid_payload` (a transition or effect " +
8547
+ "you may not report); 503 `busy` (retry).",
8548
+ inputSchema: {
8549
+ type: "object",
8550
+ properties: {
8551
+ story_id: { type: "string", description: "The story UUID." },
8552
+ claim_epoch: { type: "integer", description: "The epoch your claim returned." },
8553
+ from: { type: "string", description: "The stage the story is at." },
8554
+ to: { type: "string", description: "The stage to move it to." },
8555
+ edge: {
8556
+ type: "string",
8557
+ description: "Which transition when from -> to has more than one; default `forward`.",
8558
+ },
8559
+ reason: {
8560
+ type: "string",
8561
+ description: "Required entering `escalated` and on `merge_refused`; stored as untrusted text.",
8562
+ },
8563
+ head_sha: {
8564
+ type: "string",
8565
+ description: "The checkpoint commit, full lowercase hex, when entering `ci`.",
8566
+ },
8567
+ },
8568
+ required: ["story_id", "claim_epoch", "from", "to"],
8569
+ },
8570
+ },
8284
8571
  {
8285
8572
  name: "thread_checkpoint",
8286
8573
  description:
@@ -8340,7 +8627,9 @@ const TOOLS = [
8340
8627
  "REQUEST A REVIEW of a story's change thread (POST /api/v1/stories/:id/thread/reviews). " +
8341
8628
  "loopctl places it on `runner_id` as a runner dispatch of kind `review` — the runner " +
8342
8629
  "must DECLARE `review` on join — for the next round, on the story's latest checkpoint " +
8343
- "of the current claim. It claims nothing and returns NO credential: the review's " +
8630
+ "of the current claim. The claim may be a runner's or one a session made itself " +
8631
+ "(US-45.9); either way the reviewer is the runner's agent, which may not be the claimant " +
8632
+ "or an agent that recorded a checkpoint of the thread. It claims nothing and returns NO credential: the review's " +
8344
8633
  "findings and verdict come back over that runner's socket, never through this tool. " +
8345
8634
  "Rounds are counted per claim (a story claimed again starts at round 1). Round 2 " +
8346
8635
  "always follows round 1; round 3 only when a material round-2 finding names a checkpoint " +
@@ -8351,7 +8640,9 @@ const TOOLS = [
8351
8640
  "with the same one. Refusals: 403 for an agent key; 409 `no_checkpoint`, " +
8352
8641
  "`review_claim_ended` (no live claim to review), `review_ceiling_reached`, `reviewer_not_separate` (the runner's agent is the claimant, " +
8353
8642
  "recorded a checkpoint, or is on the implementer's lineage chain), " +
8354
- "`implementer_dispatch_required`, `dispatch_id_conflict`, `review_dispatch_refused` " +
8643
+ "`unresolvable_dispatch_lineage` (the implementer's lineage cannot be read, so no " +
8644
+ "reviewer can be shown separate: refused closed), " +
8645
+ "`dispatch_id_conflict`, `review_dispatch_refused` " +
8355
8646
  "(the runner refused or superseded that review: place a new one with a new " +
8356
8647
  "`dispatch_id`), and the push's own " +
8357
8648
  "(`runner_not_connected`, `kind_not_supported`, `budget_unset`, ...); 422 " +
@@ -9600,6 +9891,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9600
9891
 
9601
9892
  case "update_story":
9602
9893
  return await updateStory(args);
9894
+ case "list_epics":
9895
+ return await listEpics(args);
9896
+ case "create_epic":
9897
+ return await createEpic(args);
9898
+ case "get_epic":
9899
+ return await getEpic(args);
9900
+ case "update_epic":
9901
+ return await updateEpic(args);
9902
+ case "delete_epic":
9903
+ return await deleteEpic(args);
9904
+ case "delete_story":
9905
+ return await deleteStory(args);
9906
+ case "epic_progress":
9907
+ return await epicProgress(args);
9603
9908
 
9604
9909
  case "import_stories":
9605
9910
  return await importStories(args);
@@ -9937,6 +10242,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9937
10242
  case "thread_fix":
9938
10243
  return await threadFix(args);
9939
10244
 
10245
+ case "thread_stage_report":
10246
+ return await threadStageReport(args);
10247
+
9940
10248
  case "resolve_escalation":
9941
10249
  return await resolveEscalation(args);
9942
10250
 
package/lib/epics.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The epics surface — `list_epics`, `create_epic`, `get_epic`, `update_epic`, `delete_epic`
3
+ * and `epic_progress` — the MCP half of the `EpicController` routes (loopctl #876).
4
+ *
5
+ * WHY IT EXISTS. The routes were served and no tool reached them, so a session could create
6
+ * an epic through `import_stories` and then not name it: import answers COUNTS
7
+ * (`epics_created: 1`), never ids. Enrolling an intake source needs a `target_epic_id`, and
8
+ * the only way to learn one was to create a throwaway story in the epic and read `epic_id`
9
+ * back off `list_stories`. `import_stories` was not widened to return ids instead: an epic
10
+ * also has to be listed, read, corrected and measured after it exists, which a create
11
+ * response cannot do, so the tools cover the whole surface.
12
+ *
13
+ * WHAT THE ENDPOINTS ACCEPT, read from `lib/loopctl_web/controllers/epic_controller.ex`:
14
+ *
15
+ * - create casts `number`, `title`, `description`, `phase`, `position` (default 0) and
16
+ * `metadata` (default {}); `number` is required and cannot change afterwards.
17
+ * - update takes `title`, `description`, `phase`, `position`, `metadata` and DROPS every
18
+ * nil, so a null writes nothing, and a request naming none of them is a 200 that changes
19
+ * nothing. Both shapes are refused here, as `update_story` refuses them. An empty string
20
+ * is what CLEARS `description` or `phase` (a blank `title` is a 422). `metadata` is
21
+ * REPLACED whole, never merged.
22
+ * - index pages (`page`, `page_size`) and filters by `phase`.
23
+ *
24
+ * KEYS, from the controller's plugs: reads are `role: :agent` (any key); create and update
25
+ * are `role: :orchestrator` with the hierarchy, and human-anchored; delete is `role: :user`,
26
+ * human-anchored, and CASCADES to the epic's stories — refused 422 when a dispatch, capability
27
+ * token or verification run references a story in it (those do not cascade) or an active
28
+ * intake source targets the epic. `delete_story` is `lib/story-update.js`'s.
29
+ */
30
+
31
+ import { uuid as uuidRefusal } from "./delivery-loop.js";
32
+ import { namedBody } from "./story-update.js";
33
+
34
+ const UPDATABLE = ["title", "description", "phase", "position", "metadata"];
35
+
36
+ function refuse(body) {
37
+ return { error: true, status: 0, body };
38
+ }
39
+
40
+ // A malformed id is refused locally rather than sent, through the SAME guard the other tools
41
+ // use (`lib/delivery-loop.js`'s `uuid`), so a bad id reads the same whichever tool it reached.
42
+ function badId(name, value) {
43
+ return uuidRefusal(value, name);
44
+ }
45
+
46
+ // Each builder is ONE template literal, which is the shape `test/tool-surface.js` resolves to
47
+ // the route it names: the query string after `/epics` contributes nothing to the route.
48
+ export function projectEpicsPath(projectId, query = {}) {
49
+ return `/api/v1/projects/${encodeURIComponent(projectId)}/epics${epicsQuery(query)}`;
50
+ }
51
+
52
+ export function epicPath(epicId) {
53
+ return `/api/v1/epics/${encodeURIComponent(epicId)}`;
54
+ }
55
+
56
+ export function epicProgressPath(epicId) {
57
+ return `/api/v1/epics/${encodeURIComponent(epicId)}/progress`;
58
+ }
59
+
60
+ function epicsQuery({ page, page_size, phase } = {}) {
61
+ const params = new URLSearchParams();
62
+ // `!= null`, not `!== undefined`: a client sending an optional argument as null means
63
+ // "not named", and `phase=null` would filter on the literal phase "null".
64
+ if (page != null) params.set("page", String(page));
65
+ if (page_size != null) params.set("page_size", String(page_size));
66
+ if (phase != null) params.set("phase", String(phase));
67
+ const query = params.toString();
68
+ return query === "" ? "" : `?${query}`;
69
+ }
70
+
71
+ export async function listEpics({ project_id, page, page_size, phase } = {}, { apiCall } = {}) {
72
+ const refused = badId("project_id", project_id);
73
+ if (refused) return refused;
74
+
75
+ // A cleared phase is stored as NULL, so `phase=` would filter on the empty string and match
76
+ // nothing: an empty list that reads as "no epics". Refused rather than answered wrongly.
77
+ if (phase === "") {
78
+ return refuse(
79
+ "`phase` cannot be empty: no filter selects epics without a phase. Leave it out to list " +
80
+ "every epic.",
81
+ );
82
+ }
83
+
84
+ return apiCall("GET", projectEpicsPath(project_id, { page, page_size, phase }));
85
+ }
86
+
87
+ export async function createEpic(
88
+ { project_id, number, title, description, phase, position, metadata } = {},
89
+ { apiCall } = {},
90
+ ) {
91
+ const refused = badId("project_id", project_id);
92
+ if (refused) return refused;
93
+
94
+ // An integer, as the input schema declares, and at least 1, which the server requires.
95
+ if (!Number.isInteger(number) || number < 1) {
96
+ return refuse("`number` is required and must be an integer of at least 1; it is fixed for good.");
97
+ }
98
+
99
+ if (typeof title !== "string" || title.trim() === "") {
100
+ return refuse("`title` is required.");
101
+ }
102
+
103
+ const body = { number, title };
104
+ if (description !== undefined) body.description = description;
105
+ if (phase !== undefined) body.phase = phase;
106
+ if (position !== undefined) body.position = position;
107
+ if (metadata !== undefined) body.metadata = metadata;
108
+
109
+ return apiCall("POST", projectEpicsPath(project_id), body);
110
+ }
111
+
112
+ export async function getEpic({ epic_id } = {}, { apiCall } = {}) {
113
+ return badId("epic_id", epic_id) ?? apiCall("GET", epicPath(epic_id));
114
+ }
115
+
116
+ export async function updateEpic(args = {}, { apiCall } = {}) {
117
+ const refused = badId("epic_id", args.epic_id);
118
+ if (refused) return refused;
119
+
120
+ // A null is refused, as `update_story` refuses it (`namedBody`). An EMPTY string is what
121
+ // clears a field: it survives the controller's nil filter and the changeset casts it to nil,
122
+ // so `description: ""` or `phase: ""` erases it (a blank `title` is a 422).
123
+ const { body, error } = namedBody(
124
+ args,
125
+ UPDATABLE,
126
+ 'To clear `description` or `phase`, send an empty string ("").',
127
+ );
128
+ if (error) return refuse(error);
129
+
130
+ if (Object.keys(body).length === 0) {
131
+ return refuse(
132
+ `Name at least one of ${UPDATABLE.join(", ")}: the endpoint drops every absent field, ` +
133
+ "so a request with none of them answers 200 and changes nothing.",
134
+ );
135
+ }
136
+
137
+ return apiCall("PATCH", epicPath(args.epic_id), body);
138
+ }
139
+
140
+ export async function deleteEpic({ epic_id } = {}, { apiCall } = {}) {
141
+ return badId("epic_id", epic_id) ?? apiCall("DELETE", epicPath(epic_id), null);
142
+ }
143
+
144
+ export async function epicProgress({ epic_id } = {}, { apiCall } = {}) {
145
+ return badId("epic_id", epic_id) ?? apiCall("GET", epicProgressPath(epic_id));
146
+ }
147
+
148
+ // The key an epic WRITE travels on: `role: :orchestrator` WITH the hierarchy
149
+ // (epic_controller.ex), so a user key passes too. The first of LOOPCTL_ORCH_KEY and
150
+ // LOOPCTL_USER_KEY that is set is named, to be sent VERBATIM; `null` when neither is, and the
151
+ // caller's default key selection applies.
152
+ export function epicWriteKeyHint(env = process.env) {
153
+ if (env.LOOPCTL_ORCH_KEY) return "LOOPCTL_ORCH_KEY";
154
+ if (env.LOOPCTL_USER_KEY) return "LOOPCTL_USER_KEY";
155
+ return null;
156
+ }
157
+
158
+ // The key a DELETE travels on: LOOPCTL_USER_KEY, sent VERBATIM when it is set, so a global
159
+ // LOOPCTL_API_KEY of a lesser role cannot displace it; `null` when it is not, and the caller's
160
+ // default selection applies. `role: :user` WITH the hierarchy, so the global key may well pass.
161
+ export function deleteKeyHint(env = process.env) {
162
+ return env.LOOPCTL_USER_KEY ? "LOOPCTL_USER_KEY" : null;
163
+ }
@@ -361,3 +361,63 @@ export function buildCorpusSearchBody({ query, query_vector, lanes, limit } = {}
361
361
  if (limit != null) body.limit = limit;
362
362
  return body;
363
363
  }
364
+
365
+ // `true`/`false` or their strings, and nothing else (loopctl #880); `undefined` for anything
366
+ // else, which `importRefusal` turns into a refusal before a path is ever built.
367
+ function importFlag(value) {
368
+ if (value === undefined || value === null || value === false || value === "false") return false;
369
+ if (value === true || value === "true") return true;
370
+ return undefined;
371
+ }
372
+
373
+ /**
374
+ * Why an `import_stories` call must not be sent, or `null`. `merge` and `report_orphans` take
375
+ * `true`/`false` (or their strings) and nothing else, as the server does (loopctl #880): a value
376
+ * that is neither is REFUSED rather than read as off, which would silently run a fresh import
377
+ * or drop the orphan list a caller asked for; and `report_orphans` without `merge` is refused,
378
+ * since orphans are a merge's report.
379
+ *
380
+ * @param {{ merge?: unknown, report_orphans?: unknown }} [args]
381
+ * @returns {string | null}
382
+ */
383
+ export function importRefusal({ merge, report_orphans } = {}) {
384
+ if (importFlag(merge) === undefined) return "`merge` must be true or false.";
385
+ if (importFlag(report_orphans) === undefined) return "`report_orphans` must be true or false.";
386
+ if (importFlag(report_orphans) && !importFlag(merge)) {
387
+ return "`report_orphans` needs `merge: true`: orphans are a merge's report.";
388
+ }
389
+ return null;
390
+ }
391
+
392
+ /**
393
+ * Why an `import_stories` payload must not be sent as the body, or `null`: it carries its own
394
+ * `merge` or `report_orphans`, which the server reads from the body as well (loopctl #880) and
395
+ * which would override or contradict the tool's arguments. Pass the flags as arguments.
396
+ *
397
+ * @param {unknown} payload
398
+ * @returns {string | null}
399
+ */
400
+ export function importPayloadRefusal(payload) {
401
+ if (!payload || typeof payload !== "object") return null;
402
+ const carried = ["merge", "report_orphans"].filter((key) => key in payload);
403
+ return carried.length === 0
404
+ ? null
405
+ : `The payload carries ${carried.map((k) => `\`${k}\``).join(" and ")}; pass ` +
406
+ "import flags as the tool's arguments, not inside the payload.";
407
+ }
408
+
409
+ /**
410
+ * Path for `import_stories`, for arguments `importRefusal` accepted. ONE template literal, the
411
+ * shape `test/tool-surface.js` resolves to the route; the id is encoded so it cannot rewrite
412
+ * the query.
413
+ *
414
+ * @param {string} projectId
415
+ * @param {{ merge?: unknown, report_orphans?: unknown }} [args]
416
+ * @returns {string}
417
+ */
418
+ export function importPath(projectId, { merge, report_orphans } = {}) {
419
+ return `/api/v1/projects/${encodeURIComponent(projectId)}/import${buildQuery([
420
+ ["merge", importFlag(merge) ? "true" : null],
421
+ ["report_orphans", importFlag(report_orphans) ? "true" : null],
422
+ ])}`;
423
+ }
@@ -100,16 +100,30 @@ export function storyPath(storyId) {
100
100
  * dropped it (`Map.reject`, `story_controller.ex:443`).
101
101
  */
102
102
  export function updateBody(args = {}) {
103
+ return namedBody(
104
+ args,
105
+ UPDATABLE,
106
+ "Send the value you want, or leave the field out.",
107
+ );
108
+ }
109
+
110
+ /**
111
+ * The PATCH body of the `fields` a caller named, for an endpoint whose controller DROPS every
112
+ * nil before the changeset sees it: `undefined` is omitted, `null` refused (it would answer 200
113
+ * with the field unchanged). Shared by `update_story` and `update_epic` (`lib/epics.js`), so the
114
+ * two refuse the same shape the same way; `remedy` is the last sentence of the refusal.
115
+ */
116
+ export function namedBody(args, fields, remedy) {
103
117
  const body = {};
104
118
 
105
- for (const field of UPDATABLE) {
119
+ for (const field of fields) {
106
120
  const value = args[field];
107
121
  if (value === undefined) continue;
108
122
  if (value === null) {
109
123
  return {
110
124
  error: `\`${field}\` cannot be set to null through this endpoint. The controller drops ` +
111
125
  `every nil before the changeset sees it, so the request would answer 200 with the ` +
112
- `field unchanged. Send the value you want, or leave the field out.`,
126
+ `field unchanged. ${remedy}`,
113
127
  };
114
128
  }
115
129
  body[field] = value;
@@ -118,6 +132,14 @@ export function updateBody(args = {}) {
118
132
  return { body };
119
133
  }
120
134
 
135
+ /**
136
+ * `DELETE /api/v1/stories/:id` — `delete_story`. Refused 404, or 422 when records kept on
137
+ * purpose (dispatches, capability tokens, verification runs) reference the story.
138
+ */
139
+ export async function deleteStory({ story_id } = {}, { apiCall } = {}) {
140
+ return uuidRefusal(story_id, "story_id") ?? apiCall("DELETE", storyPath(story_id), null);
141
+ }
142
+
121
143
  /**
122
144
  * `PATCH /api/v1/stories/:id` — correct a filed story's title, description, acceptance
123
145
  * criteria, estimate or metadata.
package/lib/threads.js CHANGED
@@ -109,6 +109,46 @@ export async function recordCheckpoint(
109
109
  );
110
110
  }
111
111
 
112
+ export function stageTransitionsPath(storyId) {
113
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/stage/transitions`;
114
+ }
115
+
116
+ /**
117
+ * `POST /api/v1/stories/:id/stage/transitions` on the key claim_story claimed with (US-45.9):
118
+ * the claimant of an INTERACTIVE thread claim reports its own story's stage transitions, as a
119
+ * runner reports a placed claim's.
120
+ */
121
+ export async function reportStage(
122
+ { story_id, claim_epoch, from, to, edge, reason, head_sha } = {},
123
+ { apiCall, env = process.env } = {},
124
+ ) {
125
+ if (!present(story_id)) return missing("story_id");
126
+ if (!present(from)) return missing("from");
127
+ if (!present(to)) return missing("to");
128
+ if (!Number.isInteger(claim_epoch) || claim_epoch < 0) {
129
+ return {
130
+ error: true,
131
+ status: 0,
132
+ body: "`claim_epoch` is required: the non-negative integer your claim returned.",
133
+ };
134
+ }
135
+
136
+ return apiCall(
137
+ "POST",
138
+ stageTransitionsPath(story_id),
139
+ compact({
140
+ claim_epoch,
141
+ from,
142
+ to,
143
+ edge,
144
+ reason,
145
+ effects: present(head_sha) ? { head_sha } : undefined,
146
+ }),
147
+ env[claimKeyVar(env)],
148
+ claimKeyVar(env),
149
+ );
150
+ }
151
+
112
152
  /** `POST /api/v1/stories/:id/thread/entries` on the key `principal` names. */
113
153
  export async function recordEntry(
114
154
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.108.0",
3
+ "version": "2.111.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",