loopctl-mcp-server 2.93.0 → 2.94.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
@@ -220,6 +220,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
220
220
  | `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. |
221
221
  | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. |
222
222
  | `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`. Needs a loopctl server with claim leases. |
223
+ | `escalate_story` | Park a story you hold for a HUMAN and stop (`POST /stories/:id/escalate`, agent key). This is what an unattended session calls INSTEAD of asking a question — a headless run has no way to ask, so escalating is the affordance. Use it when the request contradicts an existing story, KB decision or documented behaviour, when it inverts or removes behaviour a previous story deliberately added, when it is a workflow change rather than a defect fix, or when it needs a business call — not for an ordinary blocker. ONE-WAY from your side: only a human moves the story off `escalated`, so on success STOP and report. Required: `story_id`, `claim_epoch`, `reason` (verbatim, treated as untrusted data, length capped by the server in codepoints — a 400 names the bound); optional `payload` object. Idempotent under the same epoch. Refusals pass through: 409 `stale_claim_epoch`, 409 `not_claimant`, 404 `unknown_story_stage`, 400 for a malformed epoch or reason. An orchestrator or user key is 403'd on purpose. |
223
224
  | `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
224
225
  | `request_review` | Agent signals implementation is complete and ready for review. |
225
226
 
package/index.js CHANGED
@@ -46,6 +46,7 @@ import { createHandoff } from "./lib/handoff.js";
46
46
  import { readPayloadFile } from "./lib/payload-path.js";
47
47
  import { enrollRunner, listRunners, revokeRunner, runnerPool } from "./lib/runners.js";
48
48
  import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./lib/claim-lease.js";
49
+ import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
49
50
 
50
51
  // Single source of truth for the server version: the package.json this file
51
52
  // ships with (npm always includes package.json in the published tarball).
@@ -1151,6 +1152,15 @@ async function renewStoryClaim(args) {
1151
1152
  return withClaimLeaseNotice(await renewStoryClaimRequest(args, { apiCall }));
1152
1153
  }
1153
1154
 
1155
+ // #803: escalation leads with a STOP line. A session that escalates and keeps working is
1156
+ // the failure the affordance exists to prevent, and the JSON body alone does not say so.
1157
+ async function escalateStory(args) {
1158
+ const result = await escalateStoryRequest(args, { apiCall });
1159
+ const notice = escalationNotice(result);
1160
+ if (!notice) return toContent(result);
1161
+ return { content: [{ type: "text", text: notice }, ...toContent(result).content] };
1162
+ }
1163
+
1154
1164
  async function startStory({ story_id, capability }) {
1155
1165
  // Cache first, then DELIVERY of the token claim already minted, then recovery
1156
1166
  // (which mints a fresh one). Delivery also covers a legacy env-var key, whose
@@ -4085,6 +4095,56 @@ const TOOLS = [
4085
4095
  required: ["story_id", "claim_epoch"],
4086
4096
  },
4087
4097
  },
4098
+ {
4099
+ name: "escalate_story",
4100
+ description:
4101
+ "Park a story you hold for a HUMAN to decide, and stop (POST /api/v1/stories/:id/escalate). " +
4102
+ "This is what you call instead of asking a question: an unattended session has no way to " +
4103
+ "ask, so escalating is the affordance. Use it when the request contradicts an existing " +
4104
+ "story, KB decision or documented behaviour; when it inverts or removes behaviour a " +
4105
+ "previous story deliberately added; when it is a workflow change rather than a defect fix; " +
4106
+ "or when it needs a business call. Do NOT use it for an ordinary blocker you can work " +
4107
+ "through. It is ONE-WAY from your side: only a human moves the story off `escalated`, so " +
4108
+ "after a success, stop working the story and report that you escalated and why. Uses the " +
4109
+ "AGENT key, the same key as claim_story; an orchestrator or user key is 403'd on purpose. " +
4110
+ "IDEMPOTENT: repeating it under the same claim_epoch returns the same stage row and writes " +
4111
+ "nothing twice. Refusals pass through: 409 stale_claim_epoch (the claim has ENDED, stop " +
4112
+ "working it), 409 not_claimant (your key's agent is not the story's), 404 " +
4113
+ "unknown_story_stage (the story is not in the delivery loop), 400 for a missing or " +
4114
+ "malformed claim_epoch or reason.",
4115
+ inputSchema: {
4116
+ type: "object",
4117
+ properties: {
4118
+ story_id: {
4119
+ type: "string",
4120
+ description: "The UUID of the story you hold.",
4121
+ },
4122
+ claim_epoch: {
4123
+ type: "integer",
4124
+ minimum: 0,
4125
+ description: "The claim_epoch your claim_story result returned.",
4126
+ },
4127
+ reason: {
4128
+ type: "string",
4129
+ minLength: 1,
4130
+ description:
4131
+ "What a human has to decide, in your own words. Recorded verbatim and treated as " +
4132
+ "untrusted data everywhere it is read. The SERVER caps its length, in codepoints " +
4133
+ "(what Postgres counts, not graphemes) — no bound is repeated here, because a " +
4134
+ "number baked into a published npm package goes stale against the server that " +
4135
+ "enforces it. Over the cap is a 400 naming the bound.",
4136
+ },
4137
+ payload: {
4138
+ type: "object",
4139
+ additionalProperties: true,
4140
+ description:
4141
+ "Optional structured detail — the story or KB article it contradicts, the two " +
4142
+ "options you are between. Recorded on the stage event; at most 8000 bytes encoded.",
4143
+ },
4144
+ },
4145
+ required: ["story_id", "claim_epoch", "reason"],
4146
+ },
4147
+ },
4088
4148
  {
4089
4149
  name: "start_story",
4090
4150
  description:
@@ -8290,6 +8350,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8290
8350
 
8291
8351
  case "renew_story_claim":
8292
8352
  return await renewStoryClaim(args);
8353
+ case "escalate_story":
8354
+ return await escalateStory(args);
8293
8355
 
8294
8356
  case "start_story":
8295
8357
  return await startStory(args);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Story escalation (loopctl #803, design §8; MCP side under #809).
3
+ *
4
+ * `escalate_story` is the POSITIVE AFFORDANCE an unattended session has instead of a
5
+ * question. A headless run has no AskUserQuestion at all, and nothing fires when the model
6
+ * wanted to ask — no tool call is attempted, so no hook sees anything — which is why the
7
+ * session must be able to DO something rather than be detected wanting to.
8
+ *
9
+ * It is one-way from the session's side: the story parks at the `escalated` delivery stage
10
+ * and only a HUMAN principal moves it off. So the result leads with a line saying to STOP,
11
+ * because a session that escalates and keeps working is the failure this exists to prevent
12
+ * and a bare JSON body does not say so.
13
+ *
14
+ * SINGLE SOURCE OF TRUTH. index.js injects `apiCall`; the unit suite runs this code with a
15
+ * recording fake. The KEY is selected here, from the injected env, so the key an escalation
16
+ * travels on is testable behaviour rather than a source pattern — and it must be the AGENT
17
+ * key, because the endpoint is `exact_role: :agent`: the human key that RESOLVES an
18
+ * escalation is 403'd, which is the separation the route exists inside.
19
+ *
20
+ * Only `story_id` and a non-empty `reason` are validated client-side. Everything else is the
21
+ * server's judgement and passes through unchanged: 409 stale_claim_epoch (the claim ended —
22
+ * stop working it), 409 not_claimant, 404 unknown_story_stage, 400 for a malformed epoch.
23
+ */
24
+
25
+ export function escalatePath(storyId) {
26
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/escalate`;
27
+ }
28
+
29
+ /**
30
+ * `POST /api/v1/stories/:id/escalate {claim_epoch, reason, payload?}` on the AGENT key.
31
+ * `claim_epoch` is forwarded exactly as given, and `payload` is omitted when absent rather
32
+ * than sent as null.
33
+ */
34
+ export async function escalateStory(
35
+ { story_id, claim_epoch, reason, payload } = {},
36
+ { apiCall, env = process.env } = {},
37
+ ) {
38
+ if (typeof story_id !== "string" || story_id.trim() === "") {
39
+ return { error: true, status: 0, body: "`story_id` is required." };
40
+ }
41
+ if (typeof reason !== "string" || reason.trim() === "") {
42
+ return {
43
+ error: true,
44
+ status: 0,
45
+ body: "`reason` is required: say what a human has to decide, in your own words.",
46
+ };
47
+ }
48
+
49
+ const body = { claim_epoch, reason };
50
+ if (payload !== undefined && payload !== null) body.payload = payload;
51
+
52
+ return apiCall("POST", escalatePath(story_id), body, env.LOOPCTL_AGENT_KEY);
53
+ }
54
+
55
+ /**
56
+ * The line that leads a successful escalation, or null on a refusal. It says STOP, because
57
+ * the whole point of the call is that the session hands the decision over and does no more
58
+ * work on the story.
59
+ */
60
+ export function escalationNotice(result) {
61
+ const stage = result && result.error !== true ? result.stage : null;
62
+ if (!stage || stage.stage !== "escalated") return null;
63
+
64
+ return (
65
+ "ESCALATED: this story is parked for a human and only a human moves it off. " +
66
+ "STOP working it now — do not implement, do not open a PR, do not pick the decision " +
67
+ "yourself. Report that you escalated and why."
68
+ );
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.93.0",
3
+ "version": "2.94.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",