loopctl-mcp-server 2.95.0 → 2.97.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
@@ -67,14 +67,44 @@ Or if installed locally:
67
67
  | Variable | Description | Default |
68
68
  |---|---|---|
69
69
  | `LOOPCTL_SERVER` | loopctl server URL | `https://loopctl.com` |
70
- | `LOOPCTL_API_KEY` | Global API key override (if set, always used) | -- |
71
- | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (verify, reject, review, import) | -- |
70
+ | `LOOPCTL_API_KEY` | Global API key override: used for every tool except the exact-key ones below, and for the `exact_role: :orchestrator` custody verbs when `LOOPCTL_ORCH_KEY` is unset | -- |
71
+ | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (report, review, import, and the `exact_role: :orchestrator` custody verbs `verify_story` / `reject_story` / `verify_all_in_epic` / `bulk_mark_complete` / `force_unclaim_story` — when it is set, it is the key those five send) | -- |
72
72
  | `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
73
- | `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)) and for destructive admin tools like `knowledge_bulk_publish`, and for the [runner tools](#runner-tools-user-key). | -- |
73
+ | `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)), for destructive admin tools like `knowledge_bulk_publish`, and for the `runner_*` tools plus `place_dispatch` and `resolve_escalation` in [Runner and delivery-loop tools](#runner-and-delivery-loop-tools). **Not for the whole of that section:** `story_stage` takes `LOOPCTL_AGENT_KEY`, and `force_unclaim_story` is `exact_role: :orchestrator`, where a user key is 403'd like any other non-member. | -- |
74
74
  | `LOOPCTL_STH_STATE_PATH` | Absolute path for the witness-protocol STH cache file (see [Witness protocol](#witness-protocol-sth)). Optional. | per-(server + key) file under the OS temp dir |
75
75
 
76
76
  Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
77
77
 
78
+ **The exception: tools pinned to an EXACT key.** Some endpoints are gated `exact_role:`,
79
+ where the role hierarchy does NOT apply — a higher-privileged key is refused there exactly
80
+ as a lower one is. For those tools the global override does not silently win over the
81
+ variable the tool names, so a key you set for a specific role is the key that gets sent:
82
+
83
+ | Tool | Key | When the override applies |
84
+ |---|---|---|
85
+ | `verify_story`, `reject_story`, `verify_all_in_epic`, `bulk_mark_complete`, `force_unclaim_story` | `LOOPCTL_ORCH_KEY` (`exact_role: :orchestrator`) | Pinned **when `LOOPCTL_ORCH_KEY` is set**. With no orchestrator key, `LOOPCTL_API_KEY` is still sent — set it to an orchestrator-role key and these work. |
86
+ | `place_dispatch`, `resolve_escalation` | `LOOPCTL_USER_KEY` (an unlineaged human principal) | Always pinned; `LOOPCTL_API_KEY` is never consulted. |
87
+ | `set_llm_config`, `llm_config` and the other secret-managing tools | `LOOPCTL_USER_KEY` | Always pinned; a secret operation must not run under a non-user global key. |
88
+
89
+ Why pin at all: the priority above means `LOOPCTL_API_KEY` beats the variable a tool names,
90
+ so an operator who sets `LOOPCTL_ORCH_KEY` for the custody verbs and `LOOPCTL_API_KEY` for
91
+ everything else had the orchestrator key discarded on exactly the calls it was set for. It
92
+ is **not** because the resulting 403 is confusing — `LoopctlWeb.Plugs.RequireRole` runs
93
+ first and answers with `code: "insufficient_role"` and `required_roles: ["orchestrator"]`,
94
+ which names the problem correctly.
95
+
96
+ `report_story` and `review_complete` are NOT in this table: their gates take a role RANGE
97
+ (`exact_role: [:agent, :orchestrator]` and `[:orchestrator, :user]`), so the
98
+ `LOOPCTL_API_KEY` fallback is what makes the common agent configuration work — that is
99
+ `LOOPCTL_API_KEY` set to an agent key. **`LOOPCTL_AGENT_KEY` alone is not that configuration
100
+ for these two**: the priority above never consults `LOOPCTL_AGENT_KEY` by name, only a tool's
101
+ own key, and `report_story` and `review_complete` both name `LOOPCTL_ORCH_KEY` as theirs — so
102
+ with only `LOOPCTL_AGENT_KEY` set they answer "No API key configured" before reaching their
103
+ gate. Most agent-facing tools do name `LOOPCTL_AGENT_KEY` as their own key and so do work
104
+ under it alone; these two are the exception, and `story_stage` reads it explicitly for the
105
+ same reason. The conditional pin in the first row is that same reasoning applied to the
106
+ single-role gates.
107
+
78
108
  ## First-time setup — provision your BYO LLM keys
79
109
 
80
110
  **loopctl is agent-native and strictly BYO (bring-your-own-keys).** loopctl fronts
@@ -466,9 +496,9 @@ through the same authenticated + witness/STH path as every static read tool. If
466
496
  `/retrieve/tools` fetch fails, listing degrades to the static tools (never errors).
467
497
  The generated-tool count per tenant is bounded by the per-tenant entity cap.
468
498
 
469
- ### Runner Tools (user key)
499
+ ### Runner and delivery-loop tools
470
500
 
471
- Enroll the dev machines that run the agent delivery loop, and see which are connected (issue #809). All four require `LOOPCTL_USER_KEY`; enroll and revoke also require a human-anchored tenant.
501
+ Enroll the dev machines that run the agent delivery loop, see which are connected (issue #809), place work on them and recover a story a runner would not take. The `runner_*` tools require `LOOPCTL_USER_KEY`, and enroll and revoke additionally require a human-anchored tenant. The delivery-loop verbs below do NOT share one key — each row names the key its endpoint's gate demands, and a higher-privileged key does not substitute for an `exact_role` one.
472
502
 
473
503
  | Tool | Description |
474
504
  |---|---|
@@ -476,9 +506,10 @@ Enroll the dev machines that run the agent delivery loop, and see which are conn
476
506
  | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
477
507
  | `runner_revoke` | Revoke a runner (`DELETE /api/v1/runners/:id`): its credential stops authenticating and its live socket is disconnected. The undo for `runner_enroll`. Idempotent. Required: `id`. |
478
508
  | `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, a hint. Presence converges only within a cluster. |
479
- | `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. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement` default, or `triage`), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `branch`, `base_branch`, `wall_clock_seconds`, `max_turns`. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
509
+ | `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. 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), `branch`, `base_branch`, `wall_clock_seconds`, `max_turns`. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
480
510
  | `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`. |
481
511
  | `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`. |
512
+ | `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **It frees the STAGE; it does not make the story placeable.** `place_dispatch` wants `contracted` + `queued` (see its own row in this table), and the release leaves the story at `pending`, whose only transition is `pending -> contracted` — so running `place_dispatch` next answers the identical 409 `invalid_transition`. The remedy is three calls, in this order: `force_unclaim_story`, then `contract_story`, then `place_dispatch`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Safe to run twice: on an already-pending story the stage row is written only if it is stranded behind the story's claim epoch or still in flight. No request body. Required: `story_id` (refused locally if it is not a UUID). |
482
513
 
483
514
  ### Dispatch & Chain of Custody (v2) Tools
484
515
 
package/index.js CHANGED
@@ -42,12 +42,14 @@ import {
42
42
  createGeneratedToolsRuntime,
43
43
  GENERATED_TOOL_PREFIX,
44
44
  } from "./lib/generated-tools.js";
45
+ import { exactKeyMissingMessage, orchestratorKeyArgs } from "./lib/custody-key.js";
45
46
  import { createHandoff } from "./lib/handoff.js";
46
47
  import { readPayloadFile } from "./lib/payload-path.js";
47
48
  import { enrollRunner, listRunners, revokeRunner, runnerPool } from "./lib/runners.js";
48
49
  import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./lib/claim-lease.js";
49
50
  import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
50
51
  import {
52
+ forceUnclaimStory as forceUnclaimStoryRequest,
51
53
  placeDispatch as placeDispatchRequest,
52
54
  resolveEscalation as resolveEscalationRequest,
53
55
  storyStage as storyStageRequest,
@@ -149,12 +151,38 @@ function cachedClientContextHeader() {
149
151
  return clientContextHeaderCache;
150
152
  }
151
153
 
152
- async function apiCall(method, path, body, keyOverride, { exactKey = false, timeoutMs } = {}) {
154
+ async function apiCall(
155
+ method,
156
+ path,
157
+ body,
158
+ keyOverride,
159
+ { exactKey = false, timeoutMs, keyHint } = {},
160
+ ) {
153
161
  const url = `${getBaseUrl()}${path}`;
154
- // Secret-managing tools pass exactKey:true so the request uses the EXACT
155
- // role-pinned key (LOOPCTL_USER_KEY) and does NOT fall back to the global
156
- // LOOPCTL_API_KEY override — a secret op must never silently run under a
157
- // non-user global key (review #12).
162
+ // `exactKey` sends `keyOverride` VERBATIM instead of consulting `resolveKey`
163
+ // (defined above), whose priority is LOOPCTL_API_KEY > keyOverride >
164
+ // LOOPCTL_ORCH_KEY — so without it a global LOOPCTL_API_KEY displaces the key a
165
+ // tool names. Two families pass it, for two different reasons:
166
+ //
167
+ // - USER-ROLE-PINNED tools pass LOOPCTL_USER_KEY with `exactKey: true`
168
+ // UNCONDITIONALLY. Named rather than cited by line, because a line number here
169
+ // drifts with every edit above it: `llmConfig` / `setLlmConfig`, the egress
170
+ // allowlist verbs (`clearLocalOnly`, `declareTrustedEndpoint`,
171
+ // `revokeTrustedEndpoint`), the WebAuthn enrollment and revocation verbs
172
+ // (`requestAuthenticatorChallenge`, `enrollAuthenticator`,
173
+ // `requestAuthenticatorRevokeChallenge`, `revokeAuthenticator`), and the shared
174
+ // `runnerDeps()` / `deliveryDeps()` factories — the latter covering
175
+ // `place_dispatch` and `resolve_escalation`, which need an unlineaged human
176
+ // principal. `grep -n 'exactKey: true' index.js` is the current list. None of
177
+ // them may silently run under a global key of another role (review #12).
178
+ // - The `exact_role: :orchestrator` CUSTODY verbs pass it CONDITIONALLY, and
179
+ // nothing here decides that: `orchestratorKeyArgs` (`lib/custody-key.js`)
180
+ // returns `exactKey` only when LOOPCTL_ORCH_KEY is set, and returns no options
181
+ // at all when only LOOPCTL_API_KEY is — an orchestrator-role global key was a
182
+ // working, documented configuration before 2.97.0 and still reaches the gate.
183
+ // The reason for pinning is the operator's EXPRESSED CHOICE of variable, never
184
+ // the shape of the server's 403; `lib/custody-key.js` reads the plug and shows
185
+ // why that 403 was never ambiguous.
158
186
  const key = exactKey ? keyOverride : resolveKey(keyOverride);
159
187
 
160
188
  if (!key) {
@@ -162,7 +190,12 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false, time
162
190
  error: true,
163
191
  status: 0,
164
192
  body: exactKey
165
- ? "No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to manage LLM configuration."
193
+ ? // `keyHint` names the env var THIS site is pinned to. Without it the message
194
+ // below talks about LLM configuration, which is right for set_llm_config and
195
+ // actively misleading for a custody verb that is missing its orchestrator key.
196
+ keyHint
197
+ ? exactKeyMissingMessage(keyHint)
198
+ : "No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to manage LLM configuration."
166
199
  : "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY.",
167
200
  };
168
201
  }
@@ -1246,21 +1279,34 @@ async function verifyStory({ story_id, summary, review_type, claim }) {
1246
1279
  if (review_type) body.review_type = review_type;
1247
1280
  if (claim) body.claim = claim;
1248
1281
 
1282
+ // `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`). When
1283
+ // LOOPCTL_ORCH_KEY is set it is sent EXACTLY, because `resolveKey` would otherwise
1284
+ // discard it in favour of a global LOOPCTL_API_KEY on the very calls it was set for.
1285
+ // When it is NOT set, the global key still goes — see lib/custody-key.js for why the
1286
+ // pin is narrowed that way, and for what the 403 from that gate actually says.
1287
+ const orch = orchestratorKeyArgs();
1288
+
1249
1289
  const result = await apiCall(
1250
1290
  "POST",
1251
1291
  `/api/v1/stories/${story_id}/verify`,
1252
1292
  body,
1253
- process.env.LOOPCTL_ORCH_KEY
1293
+ orch.override,
1294
+ orch.options,
1254
1295
  );
1255
1296
  return toContent(result);
1256
1297
  }
1257
1298
 
1258
1299
  async function rejectStory({ story_id, reason }) {
1300
+ // `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`) — see
1301
+ // lib/custody-key.js.
1302
+ const orch = orchestratorKeyArgs();
1303
+
1259
1304
  const result = await apiCall(
1260
1305
  "POST",
1261
1306
  `/api/v1/stories/${story_id}/reject`,
1262
1307
  { reason },
1263
- process.env.LOOPCTL_ORCH_KEY
1308
+ orch.override,
1309
+ orch.options,
1264
1310
  );
1265
1311
  return toContent(result);
1266
1312
  }
@@ -1269,21 +1315,31 @@ async function rejectStory({ story_id, reason }) {
1269
1315
 
1270
1316
  async function bulkMarkComplete({ stories }) {
1271
1317
  // stories: [{story_id, summary, review_type}]
1318
+ // `exact_role: :orchestrator` (`bulk_operations_controller.ex:24-25`) — see
1319
+ // lib/custody-key.js.
1320
+ const orch = orchestratorKeyArgs();
1321
+
1272
1322
  const result = await apiCall(
1273
1323
  "POST",
1274
1324
  "/api/v1/stories/bulk/mark-complete",
1275
1325
  { stories },
1276
- process.env.LOOPCTL_ORCH_KEY
1326
+ orch.override,
1327
+ orch.options,
1277
1328
  );
1278
1329
  return toContent(result);
1279
1330
  }
1280
1331
 
1281
1332
  async function verifyAllInEpic({ epic_id, review_type, summary }) {
1333
+ // `exact_role: :orchestrator` (`story_verification_controller.ex:29-30`, `:verify_all`) —
1334
+ // see lib/custody-key.js.
1335
+ const orch = orchestratorKeyArgs();
1336
+
1282
1337
  const result = await apiCall(
1283
1338
  "POST",
1284
1339
  `/api/v1/epics/${epic_id}/verify-all`,
1285
1340
  { review_type, summary },
1286
- process.env.LOOPCTL_ORCH_KEY
1341
+ orch.override,
1342
+ orch.options,
1287
1343
  );
1288
1344
  return toContent(result);
1289
1345
  }
@@ -3188,6 +3244,26 @@ async function resolveEscalation(args) {
3188
3244
  return toContent(await resolveEscalationRequest(args, deliveryDeps()));
3189
3245
  }
3190
3246
 
3247
+ // #846: force-unclaim is `exact_role: :orchestrator`
3248
+ // (`story_verification_controller.ex:29-30`), so this is the one delivery-loop verb that wants
3249
+ // the ORCH key. Same selection as the other four custody verbs: when LOOPCTL_ORCH_KEY is set it
3250
+ // is sent EXACTLY, because `resolveKey` (`index.js:132-138`) would otherwise discard it for a
3251
+ // global LOOPCTL_API_KEY on the one call it was set for; when it is not set, a global key still
3252
+ // goes, because an orchestrator-role LOOPCTL_API_KEY was a documented, working configuration
3253
+ // before 2.97.0. `orch.resolved` is undefined only when NEITHER is set, which is the one case
3254
+ // `forceUnclaimStoryRequest` refuses locally. `keyHint` rides along so the missing-key message
3255
+ // names LOOPCTL_ORCH_KEY instead of apiCall's LLM-config default (`index.js:174-181`).
3256
+ async function forceUnclaimStory(args) {
3257
+ const orch = orchestratorKeyArgs();
3258
+
3259
+ return toContent(
3260
+ await forceUnclaimStoryRequest(args, {
3261
+ orchKey: orch.resolved,
3262
+ apiCall: (method, path, body) => apiCall(method, path, body, orch.override, orch.options),
3263
+ }),
3264
+ );
3265
+ }
3266
+
3191
3267
  // US-26: Signed Tree Head retrieval
3192
3268
  async function getSth({ tenant_id }) {
3193
3269
  const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
@@ -7677,9 +7753,14 @@ const TOOLS = [
7677
7753
  type: "string",
7678
7754
  enum: ["implement"],
7679
7755
  description:
7680
- "What the session is for. `implement` is the only kind loopctl SENDS today — " +
7681
- "`x-connection.dispatchable_kinds` in the runner contract is the live list, and " +
7682
- "a kind that is not on it is refused after the claim, not before it.",
7756
+ "What the session is for. `implement` is the only kind THIS tool sends, and the " +
7757
+ "narrowness is deliberate rather than a gap in the contract: `triage` became " +
7758
+ "dispatchable in 1.10.0, but placement CLAIMS the story — that is what mints the " +
7759
+ "custody lineage — while a triage session only reads, and loopctl dispatches " +
7760
+ "triage itself from `detected` without claiming anything. Asking for `triage` " +
7761
+ "here would claim a story for a session that is only deciding whether it is work, " +
7762
+ "put it at `claimed`, and send a triage dispatch carrying no reporter words at " +
7763
+ "all, because nothing on this path builds the triage object.",
7683
7764
  },
7684
7765
  dispatch_id: {
7685
7766
  type: "string",
@@ -7740,6 +7821,52 @@ const TOOLS = [
7740
7821
  required: ["story_id", "to"],
7741
7822
  },
7742
7823
  },
7824
+ {
7825
+ name: "force_unclaim_story",
7826
+ description:
7827
+ "TAKE A STORY BACK from the agent holding it (POST /api/v1/stories/:id/force-unclaim). " +
7828
+ "Two things happen: the story resets to `agent_status: pending` with " +
7829
+ "`assigned_agent_id` cleared, AND — in the same transaction — its delivery stage row " +
7830
+ "follows the release back to `queued`.\n\n" +
7831
+ "IT FREES THE STAGE; IT DOES NOT MAKE THE STORY PLACEABLE. place_dispatch wants " +
7832
+ "`agent_status: contracted` AND stage `queued` (`Placement.claimable/2`), and the " +
7833
+ "release leaves the story at `pending`, whose only transition is `pending -> " +
7834
+ "contracted`. Run place_dispatch straight after this one and you get back the IDENTICAL " +
7835
+ "409 `invalid_transition`. THE REMEDY IS THREE CALLS, in this order: " +
7836
+ "force_unclaim_story, then contract_story, then place_dispatch.\n\n" +
7837
+ "WHEN TO REACH FOR IT. A story sitting at `claimed` with nobody on it is the residue of " +
7838
+ "a compensation that did not complete — it is NOT what a refused dispatch normally " +
7839
+ "leaves. Placement answers a runner's refusal INLINE by releasing the claim itself " +
7840
+ "(`Placement.undo_claim/5`), and if that release fails the claim lease is a further " +
7841
+ "backstop: `Progress.reclaim_expired_claim/3` releases over `:runner_lost` and requeues " +
7842
+ "the stage, unattended, once `claimed_until` has passed. Use this tool to get the story " +
7843
+ "back NOW rather than at lease expiry, or when both of those have left it held.\n\n" +
7844
+ "The release requeues from any stage a claim holds (`claimed`, `worktree`, " +
7845
+ "`implementing`, `reviewing`, `pr_open`, `ci`); a stage no claim holds keeps its stage " +
7846
+ "and is rebound to the new claim epoch; `done` and `failed` are left alone. An ESCALATED " +
7847
+ "story is not this tool's job — use resolve_escalation, which releases AND re-contracts " +
7848
+ "for you, so a story it sends to `queued` really is placeable.\n\n" +
7849
+ "REFUSALS. Requires an ORCHESTRATOR-ROLE key, in LOOPCTL_ORCH_KEY or LOOPCTL_API_KEY: " +
7850
+ "the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like " +
7851
+ "any other non-member and reaching for a higher-privileged key does NOT get past it. " +
7852
+ "When LOOPCTL_ORCH_KEY is set it is the key sent, and a global LOOPCTL_API_KEY does not " +
7853
+ "displace it. The orchestrator key must also be LINKED TO " +
7854
+ "A REGISTERED AGENT — an unlinked one is refused 400 naming that — and the tenant must be " +
7855
+ "human-anchored (403 `custody_tier_required` otherwise). 404 for an unknown story, 429 " +
7856
+ "when rate limited. A `story_id` that is not a UUID is refused here, before any call.\n\n" +
7857
+ "It does NOT touch `verified_status`, and it is safe to run twice: on an already-pending " +
7858
+ "story the stage row is written only when it needs to be — a row STRANDED behind the " +
7859
+ "story's claim epoch is rebound to it (the remedy for a row an older release left " +
7860
+ "behind) and an in-flight row is requeued, while a row already at that epoch is left " +
7861
+ "exactly as it is. It takes no request body.",
7862
+ inputSchema: {
7863
+ type: "object",
7864
+ properties: {
7865
+ story_id: { type: "string", description: "The story UUID to free." },
7866
+ },
7867
+ required: ["story_id"],
7868
+ },
7869
+ },
7743
7870
 
7744
7871
  // LCP-1 §9 signed-profile tools
7745
7872
  {
@@ -8771,6 +8898,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8771
8898
  case "resolve_escalation":
8772
8899
  return await resolveEscalation(args);
8773
8900
 
8901
+ case "force_unclaim_story":
8902
+ return await forceUnclaimStory(args);
8903
+
8774
8904
  case "register_custody_owner_key":
8775
8905
  return await registerCustodyOwnerKey(args);
8776
8906
 
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Key selection for the `exact_role: :orchestrator` custody verbs (loopctl #846 review round 2).
3
+ *
4
+ * WHICH VERBS. `verify`, `reject` and `force_unclaim` plus the aggregate `verify_all`
5
+ * (`lib/loopctl_web/controllers/story_verification_controller.ex:29-30`), and bulk
6
+ * `verify`/`reject`/`mark_complete`
7
+ * (`lib/loopctl_web/controllers/bulk_operations_controller.ex:24-25`). All are mounted
8
+ * `exact_role: :orchestrator`, where the role hierarchy does NOT apply: `RequireRole`'s
9
+ * exact-role clause tests `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user`
10
+ * or `:superadmin` key is refused there exactly as an `:agent` key is.
11
+ *
12
+ * WHAT THE PINNING IS FOR — and what it is NOT for.
13
+ *
14
+ * NOT for the error message. An earlier draft of this comment, and every copy of it that had
15
+ * spread across this package and both changelogs, claimed a 403 from that gate "reads as a
16
+ * custody refusal about the STORY rather than a misconfigured key". That is false and the
17
+ * source says so: `RequireRole.forbid/3` (`require_role.ex:112-128`) halts with 403,
18
+ * `code: "insufficient_role"`, `required_roles: ["orchestrator"]` and the message "This
19
+ * endpoint requires the orchestrator role", and the plug is mounted FIRST, so the request
20
+ * never reaches the controller and never reaches `Progress.validate_not_self_verify/4` where
21
+ * the custody 409s are raised. The role error is unambiguous. Do not reintroduce that
22
+ * justification. It came back twice after being "removed everywhere", so it is no longer swept
23
+ * by hand: `test/no_false_403_claim.test.js` fails on any assertion of it anywhere in this
24
+ * package or in either changelog. It admits a quotation like the one above, but only where a
25
+ * disproof marker sits within a sentence or so of it — a whole-paragraph rule was too loose,
26
+ * since this refutation runs a dozen lines and its closing "do not reintroduce" would have
27
+ * excused an assertion at the far end of the very block that disproves the claim.
28
+ *
29
+ * What the pinning IS for is an operator's expressed configuration. `resolveKey` in
30
+ * `index.js` reads `LOOPCTL_API_KEY || keyOverride || LOOPCTL_ORCH_KEY` — the global
31
+ * override wins over the key the tool names. So an operator who set `LOOPCTL_ORCH_KEY`
32
+ * specifically for these verbs AND a `LOOPCTL_API_KEY` of some other role for everything else
33
+ * had the orchestrator key silently discarded on precisely the calls they set it for, and paid a
34
+ * round trip to learn it. Honouring it costs nothing and is what they asked for.
35
+ *
36
+ * WHY IT IS NARROWED TO "WHEN THE ORCH KEY IS SET". Pinning unconditionally is a BEHAVIOURAL
37
+ * BREAK in a minor release, and the configuration it breaks is one the shipped docs endorsed:
38
+ * 2.96.0's `README.md:70` describes `LOOPCTL_API_KEY` as "Global API key override (if set,
39
+ * always used)". `LOOPCTL_API_KEY` set to an orchestrator-role key, with no `LOOPCTL_ORCH_KEY`
40
+ * at all, was working, documented, and accepted by the gate — the key's ROLE is what the server
41
+ * tests, and it is `:orchestrator`. An unconditional pin refuses that locally, sends nothing, and
42
+ * tells the operator their key is the wrong role when it is not. This is the same reasoning that
43
+ * leaves `report_story` and `review_complete` unpinned in `test/custody_key_pinning.test.js`,
44
+ * applied consistently.
45
+ */
46
+
47
+ /**
48
+ * The `keyOverride` and options a custody verb should hand `apiCall`, plus the key that will
49
+ * actually be sent.
50
+ *
51
+ * @param {Record<string, string|undefined>} [env]
52
+ * @returns {{override: string|undefined, options: object, resolved: string|undefined}}
53
+ * `override` is `apiCall`'s 4th argument, `options` its 5th, and `resolved` is the key the
54
+ * request will carry — `undefined` when no key is configured at all, which is the only case a
55
+ * caller may refuse locally on.
56
+ */
57
+ export function orchestratorKeyArgs(env = process.env) {
58
+ const orchKey = env.LOOPCTL_ORCH_KEY;
59
+
60
+ if (orchKey) {
61
+ // The operator named a key for this role. Send exactly it: `exactKey` makes `apiCall` use
62
+ // the override verbatim instead of `resolveKey` — the `const key = exactKey ? keyOverride :
63
+ // resolveKey(keyOverride)` line in `index.js`.
64
+ return {
65
+ override: orchKey,
66
+ options: { exactKey: true, keyHint: "LOOPCTL_ORCH_KEY" },
67
+ resolved: orchKey,
68
+ };
69
+ }
70
+
71
+ const globalKey = env.LOOPCTL_API_KEY;
72
+
73
+ if (globalKey) {
74
+ // No orchestrator key, but a global one. This is the pre-2.97.0 configuration and it is
75
+ // still allowed to reach the gate: if that key is orchestrator-role it passes, and if it is
76
+ // not, the server's own 403 names the required role. Refusing it here would break a working
77
+ // setup to pre-empt an error the server already reports correctly.
78
+ return { override: undefined, options: {}, resolved: globalKey };
79
+ }
80
+
81
+ // Nothing configured. Pin anyway, so `apiCall`'s missing-key branch (`if (!key)`, in
82
+ // `index.js`) takes the `keyHint` path and names LOOPCTL_ORCH_KEY rather than falling back to
83
+ // the LLM-config default. Callers that refuse locally (see `forceUnclaimStory`) branch on
84
+ // `resolved` being undefined.
85
+ return {
86
+ override: undefined,
87
+ options: { exactKey: true, keyHint: "LOOPCTL_ORCH_KEY" },
88
+ resolved: undefined,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * What `apiCall` says when an `exactKey` site names a variable (`keyHint`) and no key is
94
+ * configured at all.
95
+ *
96
+ * The message it replaced said "LOOPCTL_API_KEY is deliberately not a fallback for it", which
97
+ * is now false guidance: this branch is reached only when NEITHER the hinted variable nor
98
+ * LOOPCTL_API_KEY is set, and setting LOOPCTL_API_KEY to a key of the right role IS a working
99
+ * configuration (see `orchestratorKeyArgs`). Telling an operator with no keys at all that one
100
+ * of their two options does not work sends them the long way round.
101
+ *
102
+ * What it must NOT say instead is that any key will do: the gate matches the role exactly, so
103
+ * a higher-privileged key is refused there like any other non-member.
104
+ */
105
+ export function exactKeyMissingMessage(keyHint) {
106
+ return (
107
+ `No API key configured. Set ${keyHint} — or LOOPCTL_API_KEY — to a key of the exact role ` +
108
+ `this endpoint requires; its gate matches the role exactly, so a higher-privileged key is ` +
109
+ `refused there like any other. When ${keyHint} is set it is the key sent, and a global ` +
110
+ `LOOPCTL_API_KEY does not displace it.`
111
+ );
112
+ }
@@ -1,16 +1,17 @@
1
1
  /**
2
- * The delivery loop's OPERATOR verbs (loopctl #803, #850).
2
+ * The delivery loop's OPERATOR verbs (loopctl #803, #850, #846).
3
3
  *
4
- * Three tools, and they exist because the endpoints behind them were unreachable from any
5
- * session on the fleet. `POST /api/v1/runners/:runner_id/dispatches` shipped with #842 and no
6
- * tool called it; `curl` at loopctl is refused by the fleet's own guardrail, deliberately; so
4
+ * These tools exist because the endpoints behind them were unreachable from any session on the
5
+ * fleet. `POST /api/v1/runners/:runner_id/dispatches` shipped with #842 and no tool called it;
6
+ * `curl` at loopctl is refused by the fleet's own guardrail, deliberately; so
7
7
  * the only way to place a dispatch was a shell on the production node. That is the same defect
8
8
  * as `place/4` shipping with no caller, failing one layer later — the trigger exists and
9
9
  * nothing outside the app can pull it.
10
10
  *
11
11
  * Mark's rule, 2026-09-15: an operator-facing endpoint is not done until an MCP tool calls it,
12
- * in the same change. These are that rule applied to the three verbs the loop needs to be
13
- * RUNNABLE (place a dispatch) and OBSERVABLE (read a story's stage, resolve an escalation).
12
+ * in the same change. These are that rule applied to the verbs the loop needs to be RUNNABLE
13
+ * (place a dispatch), OBSERVABLE (read a story's stage) and RECOVERABLE (resolve an
14
+ * escalation, free a story a runner refused).
14
15
  *
15
16
  * KEY SELECTION is behaviour, not a convention, so it lives here where a test can see it:
16
17
  *
@@ -19,6 +20,13 @@
19
20
  * tree, and an agent key is refused `insufficient_role` before anything is written.
20
21
  * - `resolve_escalation` is the HUMAN half of the escalation pair: a `:user` key no dispatch
21
22
  * minted. The agent key that raises an escalation is 403'd on it by design.
23
+ * - `force_unclaim_story` is gated `exact_role: :orchestrator`
24
+ * (`story_verification_controller.ex:29-30`), so it needs an ORCHESTRATOR-ROLE key and a
25
+ * higher-privileged one does NOT substitute: `RequireRole`'s exact-role clause tests
26
+ * `api_key.role == exact_role` (`require_role.ex:65-75`), so a `:user` or `:superadmin`
27
+ * key is 403'd there exactly as an agent key is. That is a chain-of-custody gate, not an
28
+ * oversight. WHICH ENV VAR carries that key is a separate question, and `index.js` decides
29
+ * it in `lib/custody-key.js`: LOOPCTL_ORCH_KEY when set, else LOOPCTL_API_KEY.
22
30
  * - `story_stage` is a read and takes whatever key the caller has.
23
31
  *
24
32
  * SINGLE SOURCE OF TRUTH: `index.js` injects `apiCall`, and the unit suite runs this code
@@ -29,14 +37,88 @@ const MISSING_USER_KEY =
29
37
  "LOOPCTL_USER_KEY is required: this verb claims a story and mints a custody dispatch, " +
30
38
  "which only an unlineaged user key may do.";
31
39
 
40
+ const MISSING_ORCH_KEY =
41
+ "No API key is configured. Set LOOPCTL_ORCH_KEY to an orchestrator-role key (or " +
42
+ "LOOPCTL_API_KEY to one). Force-unclaim is gated `exact_role: :orchestrator`, so a user or " +
43
+ "superadmin key is 403'd there like any other non-member: a higher-privileged key is not a " +
44
+ "way past it.";
45
+
32
46
  function refuse(body) {
33
47
  return { error: true, status: 0, body };
34
48
  }
35
49
 
50
+ /**
51
+ * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`.
52
+ *
53
+ * NOT on `place_dispatch`'s `dispatch_id`, which is an id and is forwarded unchecked. That is a
54
+ * decision and not an omission: it travels in the request BODY, and `Placement.place/4` casts it
55
+ * with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:786-791`) as the second clause of its
56
+ * `with` — before the caller is resolved, before anything is minted and before the claim — so a
57
+ * malformed one answers `422 invalid_payload` with `details: ["dispatch_id: must be a UUID"]`
58
+ * (`lib/loopctl_web/controllers/dispatch_placement_controller.ex:321-326`). That names the
59
+ * parameter and the shape, costs nothing on the server, and is the opposite of the ambiguity
60
+ * below. There is nothing here for a client check to disambiguate. An earlier draft of this
61
+ * comment said the check covered "every id these verbs take"; it never did.
62
+ *
63
+ * WHY THE PATH IDS DO NEED IT — stated correctly, since an earlier draft of this comment said a
64
+ * malformed id made the server "500", and it does not. loopctl has a deliberate backstop:
65
+ * `defimpl Plug.Exception, for: Ecto.Query.CastError` maps it to 404
66
+ * (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35` — the file also carries an
67
+ * `Ecto.CastError` impl at `:27-30` and an `Ecto.ChangeError` impl at `:37-42`; the query path
68
+ * below raises the `Ecto.Query.CastError` one), pinned by
69
+ * `test/loopctl_web/plugs/cast_error_handler_test.exs:9-12`. What the caller actually gets is
70
+ * `{"error": {"status": 404, "message": "Not found"}}` — the generic body
71
+ * `LoopctlWeb.ErrorJSON.render("404.json", …)` emits
72
+ * (`lib/loopctl_web/controllers/error_json.ex:13-15`, wired by `config/config.exs:344-347`).
73
+ *
74
+ * That 404 is the problem, not a 500. It is BYTE-IDENTICAL to the 404 for a perfectly
75
+ * well-formed id that names no story — `FallbackController` answers `{:error, :not_found}` with
76
+ * the same `%{error: %{status: 404, message: "Not found"}}`
77
+ * (`lib/loopctl_web/fallback_controller.ex:74-78`) — so it cannot tell an operator which
78
+ * of the two happened, and the two have opposite remedies: re-read the argument you passed, or
79
+ * go find the right story. The check here can tell them apart, and does it without a round trip.
80
+ *
81
+ * SCOPE OF THAT TRACE. The path read end to end is force-unclaim:
82
+ * `Progress.force_unclaim_story/3` reaches `lock_story/2` (`lib/loopctl/progress.ex:3467-3470`),
83
+ * which puts `story_id` straight into a `where` against a `:binary_id` column with no cast. The
84
+ * other verbs are NOT claimed to reach that same code. They do not need to: a shape check is
85
+ * worth its line on any argument that is interpolated into a URL path and must be a UUID,
86
+ * whatever the server would do with a value that is not one. `place_dispatch`'s `story_id` is
87
+ * the one exception in the other direction — it travels in the body, like `dispatch_id` — and it
88
+ * is checked because `story_id` is a PATH id in the three sibling verbs and `uuid()` is one
89
+ * shared check: giving the same parameter two different refusals depending on which verb took it
90
+ * is worse than checking it once too often.
91
+ *
92
+ * THE REFUSAL CARRIES `status: 0`, NOT 404, and that is deliberate. Every local refusal in this
93
+ * client uses it — `refuse()` below, `apiCall`'s missing-key branch and its network/timeout
94
+ * branches (`index.js`) — and it means ONE thing: no request was sent, so no server said
95
+ * anything. The whole result object is JSON-stringified into the tool output (`toContent`,
96
+ * `index.js`), so a reader sees that 0. Stamping 404 on it instead would make a local refusal
97
+ * indistinguishable from the server's answer, which is precisely the ambiguity this check
98
+ * exists to remove. A caller branching on the status therefore sees `0` where it previously saw
99
+ * `404`; that is a shape change and the 2.97.0 CHANGELOG entry says so.
100
+ *
101
+ * The refusal names the SHAPE and never echoes the value. A malformed id is frequently a
102
+ * token, a path or a pasted line that landed in the wrong argument, and a tool result goes
103
+ * into the transcript.
104
+ */
105
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
106
+
36
107
  function uuid(value, field) {
37
- return typeof value === "string" && value.trim() !== ""
38
- ? null
39
- : refuse(`\`${field}\` is required.`);
108
+ if (typeof value !== "string" || value.trim() === "") {
109
+ return refuse(`\`${field}\` is required.`);
110
+ }
111
+
112
+ if (!UUID_RE.test(value)) {
113
+ return refuse(
114
+ `\`${field}\` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). ` +
115
+ `Got a ${value.length}-character string that is not one. The value is not repeated ` +
116
+ `here: a malformed id is often something pasted into the wrong argument, and a tool ` +
117
+ `result lands in the transcript.`,
118
+ );
119
+ }
120
+
121
+ return null;
40
122
  }
41
123
 
42
124
  export function placementPath(runnerId) {
@@ -51,6 +133,11 @@ export function resolvePath(storyId) {
51
133
  return `/api/v1/stories/${encodeURIComponent(storyId)}/stage/resolve`;
52
134
  }
53
135
 
136
+ /** Note the HYPHEN: the route is `force-unclaim`, not `force_unclaim`. */
137
+ export function forceUnclaimPath(storyId) {
138
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/force-unclaim`;
139
+ }
140
+
54
141
  /**
55
142
  * `POST /api/v1/runners/:runner_id/dispatches`: claim a queued story and push it to a runner.
56
143
  *
@@ -142,3 +229,70 @@ export async function resolveEscalation({ story_id, to, reason } = {}, { userKey
142
229
 
143
230
  return apiCall("POST", resolvePath(story_id), body);
144
231
  }
232
+
233
+ /**
234
+ * `POST /api/v1/stories/:id/force-unclaim`: take a story back off the agent holding it.
235
+ *
236
+ * TWO things happen. `force_unclaim_story/3` resets `agent_status` to `pending` and clears
237
+ * `assigned_agent_id` (`release_claim_changes/1`, `progress.ex:1438`); then, in the SAME
238
+ * transaction, `Stages.follow_release/5` makes the delivery stage row follow the release —
239
+ * from any stage a claim holds (`claimed`, `worktree`, `implementing`, `reviewing`, `pr_open`,
240
+ * `ci`) back to `queued`, rebound to the new claim epoch.
241
+ *
242
+ * ## IT FREES THE STAGE. IT DOES NOT MAKE THE STORY PLACEABLE.
243
+ *
244
+ * This comment used to say the second half was "what makes the story PLACEABLE again", and
245
+ * that is false. `Placement.claimable/2` (`placement.ex:482-490`) wants `agent_status ==
246
+ * :contracted` AND stage `queued`; the release leaves the story at `:pending`, and
247
+ * `@valid_transitions` (`progress.ex:3480`) has `pending: :contracted` and nothing else — so
248
+ * `place_dispatch` run straight afterwards answers the IDENTICAL 409 `invalid_transition`.
249
+ *
250
+ * The remedy is THREE CALLS, in this order, and the tool descriptions say so:
251
+ * `force_unclaim_story`, then `contract_story`, then `place_dispatch`. (An earlier draft called
252
+ * it "two steps" and then named three, which is worse than saying nothing: an operator counting
253
+ * steps runs two of the three and takes the 409 this copy exists to prevent.)
254
+ * (`resolve_escalation` is the one that does both for you: `Escalations.prepare_story/5`
255
+ * releases AND re-contracts on the `queued` route, `escalations.ex:335-342`.)
256
+ *
257
+ * ## WHEN A STORY IS ACTUALLY PARKED
258
+ *
259
+ * Not on an ordinary refusal — that path self-heals. `Placement.place/4` answers a
260
+ * `Runners.dispatch/3` refusal INLINE with `undo_claim/5` (`placement.ex:562`, `:706-711`),
261
+ * which releases the claim through this same function, unrecords the session dispatch and
262
+ * revokes it. If that release itself fails, the claim lease is a further backstop:
263
+ * `Progress.reclaim_expired_claim/3` (`progress.ex:1612`) releases over `:runner_lost` and
264
+ * requeues the stage, swept by `ReclaimExpiredClaimsWorker` every five minutes once
265
+ * `claimed_until` has passed.
266
+ *
267
+ * So a story sitting at `claimed` with nobody on it is the RESIDUE OF A FAILED COMPENSATION,
268
+ * not what a refusal normally leaves. Reach for this tool to get the story back now instead of
269
+ * at lease expiry, or when both of those have left it held.
270
+ *
271
+ * No request body — the story is named in the path.
272
+ *
273
+ * ORCHESTRATOR ROLE, and exactly that: the action is `exact_role: :orchestrator`
274
+ * (`story_verification_controller.ex:29-30`), so a user or superadmin key is refused —
275
+ * `RequireRole`'s exact-role clause tests `api_key.role == exact_role`
276
+ * (`require_role.ex:65-75`).
277
+ *
278
+ * WHICH ENV VAR supplies that key is `index.js`'s call, made in `lib/custody-key.js`:
279
+ * LOOPCTL_ORCH_KEY is sent EXACTLY when it is set, so `resolveKey` cannot discard an
280
+ * operator's deliberate choice in favour of a global LOOPCTL_API_KEY; when it is not set, the
281
+ * global key still goes, because an orchestrator-role LOOPCTL_API_KEY was a working and
282
+ * documented configuration before 2.97.0. The refusal below fires only when NEITHER is set.
283
+ *
284
+ * NOT because the 403 would be confusing. An earlier draft of this paragraph said a wrong-role
285
+ * global key "would produce a 403 that reads like the story being unclaimable rather than the
286
+ * key being wrong". That is false: `RequireRole` is mounted FIRST and halts with 403,
287
+ * `code: "insufficient_role"`, `required_roles: ["orchestrator"]` and "This endpoint requires
288
+ * the orchestrator role" (`require_role.ex:112-128`), so the request never reaches the
289
+ * controller and never reaches the custody 409s in `Progress`. The role error names the role.
290
+ */
291
+ export async function forceUnclaimStory({ story_id } = {}, { orchKey, apiCall } = {}) {
292
+ if (!orchKey) return refuse(MISSING_ORCH_KEY);
293
+
294
+ const bad = uuid(story_id, "story_id");
295
+ if (bad) return bad;
296
+
297
+ return apiCall("POST", forceUnclaimPath(story_id), null);
298
+ }
package/lib/handoff.js CHANGED
@@ -19,9 +19,17 @@
19
19
  * lib/http-helpers.js). The three HTTP calls are INJECTED (`deps`), so every branch
20
20
  * below is testable with fakes and no network.
21
21
  *
22
- * NEVER attempts `create_project`. A work project is human-anchor-gated by design
23
- * (#505); trying it first is exactly the dead-end #517 hit, and its 403 reads as a wall
24
- * rather than a redirect.
22
+ * NEVER attempts `create_project`. A work project is human-anchor-gated by design, so on an
23
+ * agent-rooted tenant it cannot succeed and the attempt is a wasted round trip — that is the
24
+ * dead end #517 hit. NOT because the 403 is unhelpful: an earlier draft of this comment said
25
+ * it "reads as a wall rather than a redirect", and #505 is precisely what stopped that being
26
+ * true. `ProjectController` mounts `RequireHumanAnchor` on `:create` with an `alternative:`
27
+ * naming `create_kb_scope` (`lib/loopctl_web/controllers/project_controller.ex:46-58`), which
28
+ * the plug renders as `remediation.agent_native_alternative`
29
+ * (`lib/loopctl_web/plugs/require_human_anchor.ex:166-169`), and it mounts that tier gate
30
+ * BEFORE `RequireRole` (`:62`) so an agent-role key reads THAT 403 rather than
31
+ * `insufficient_role`. The 403 points at the kb scope. This code goes straight there because
32
+ * the round trip buys nothing, not because the answer would confuse anyone.
25
33
  */
26
34
 
27
35
  export const HANDOFF_KEY_PREFIX = "handoff:";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.95.0",
3
+ "version": "2.97.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",