loopctl-mcp-server 2.101.0 → 2.104.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/index.js CHANGED
@@ -49,11 +49,18 @@ import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./l
49
49
  import { escalateStory as escalateStoryRequest, escalationNotice } from "./lib/escalation.js";
50
50
  import {
51
51
  forceUnclaimStory as forceUnclaimStoryRequest,
52
+ mergePrecondition as mergePreconditionRequest,
52
53
  placeDispatch as placeDispatchRequest,
53
54
  resolveEscalation as resolveEscalationRequest,
54
55
  storyStage as storyStageRequest,
55
56
  } from "./lib/delivery-loop.js";
56
57
  import { updateStory as updateStoryRequest } from "./lib/story-update.js";
58
+ import {
59
+ enrollIntakeSource,
60
+ listIntakeSources,
61
+ revokeIntakeSource,
62
+ updateIntakeSource,
63
+ } from "./lib/intake-sources.js";
57
64
  import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
58
65
  import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
59
66
 
@@ -1053,6 +1060,18 @@ async function listReadyStories({ project_id, page, page_size }) {
1053
1060
  return toContentCompact(result);
1054
1061
  }
1055
1062
 
1063
+ async function listBlockedStories({ project_id, page, page_size }) {
1064
+ const params = new URLSearchParams();
1065
+ if (project_id != null) params.set("project_id", project_id);
1066
+ if (page != null) params.set("page", String(page));
1067
+ if (page_size != null)
1068
+ params.set("page_size", String(Math.min(page_size, SERVER_MAX_STORY_PAGE_SIZE)));
1069
+
1070
+ const qs = params.toString();
1071
+ const result = await apiCall("GET", `/api/v1/stories/blocked${qs ? `?${qs}` : ""}`);
1072
+ return toContentCompact(result);
1073
+ }
1074
+
1056
1075
  async function getStory({ story_id }) {
1057
1076
  const result = await apiCall("GET", `/api/v1/stories/${story_id}`);
1058
1077
  return toContent(result);
@@ -3289,6 +3308,61 @@ async function forceUnclaimStory(args) {
3289
3308
  );
3290
3309
  }
3291
3310
 
3311
+ // Epic 44, US-44.1: the merge gate's second run. Same key selection as force-unclaim — the
3312
+ // action is `exact_role: [:orchestrator, :user]` and LOOPCTL_ORCH_KEY is pinned when set.
3313
+ async function mergePreconditionTool(args) {
3314
+ const orch = orchestratorKeyArgs();
3315
+
3316
+ return toContent(
3317
+ await mergePreconditionRequest(args, {
3318
+ orchKey: orch.resolved,
3319
+ apiCall: (method, path, body) => apiCall(method, path, body, orch.override, orch.options),
3320
+ }),
3321
+ );
3322
+ }
3323
+
3324
+ // #803/#846: GitHub intake sources — the row that makes a webhook possible, and the binding
3325
+ // `place_dispatch` reads `repo` and `base_branch` from. The logic, including how the enrol
3326
+ // path keeps the webhook secret out of the tool result, lives in lib/intake-sources.js.
3327
+ //
3328
+ // THE FACTORY IS DECLARED BELOW THESE FOUR, not above them, and that is load-bearing rather
3329
+ // than a style: several sibling guards read a handler's source as the slice from its own
3330
+ // `async function` to the NEXT one (`delivery_loop_tools.test.js`, `custody_key_pinning.js`,
3331
+ // `mcp_arg_forwarding.js`'s `functionSource`). A plain `function` declaration sitting between
3332
+ // two handlers is swallowed by the earlier one's slice — putting `intakeDeps` above
3333
+ // `intakeSourceEnroll` made `forceUnclaimStory`'s guard read this factory's
3334
+ // `process.env.LOOPCTL_USER_KEY` and correctly fail. Hoisting keeps the call sites valid.
3335
+ async function intakeSourceEnroll(args) {
3336
+ return toContent(await enrollIntakeSource(args, intakeDeps()));
3337
+ }
3338
+
3339
+ async function intakeSourceList(args) {
3340
+ return toContent(await listIntakeSources(args, intakeDeps()));
3341
+ }
3342
+
3343
+ async function intakeSourceUpdate(args) {
3344
+ return toContent(await updateIntakeSource(args, intakeDeps()));
3345
+ }
3346
+
3347
+ async function intakeSourceRevoke(args) {
3348
+ return toContent(await revokeIntakeSource(args, intakeDeps()));
3349
+ }
3350
+
3351
+ // All four take the EXACT user-role key, like the `runner_*` family: the controller is
3352
+ // `role: :user` with `RequireHumanAnchor` on the writes, and create additionally requires a
3353
+ // caller NO DISPATCH MINTED (`RequireUnlineagedCaller`) — it mints the webhook secret, a
3354
+ // credential belonging to no lineage — so a global LOOPCTL_API_KEY of another shape must never
3355
+ // stand in for it. `baseUrl` is injected so the webhook URL names the server this process is
3356
+ // actually talking to.
3357
+ function intakeDeps() {
3358
+ const userKey = process.env.LOOPCTL_USER_KEY;
3359
+ return {
3360
+ userKey,
3361
+ baseUrl: getBaseUrl(),
3362
+ apiCall: (method, path, body) => apiCall(method, path, body, userKey, { exactKey: true }),
3363
+ };
3364
+ }
3365
+
3292
3366
  // #846.6: correct a filed story. The action is `role: :orchestrator`
3293
3367
  // (`story_controller.ex:29-30`) and NOT `exact_role:`, so the hierarchy applies and a `:user`
3294
3368
  // or `:superadmin` key passes too. That is why LOOPCTL_ORCH_KEY goes through `resolveKey` as an
@@ -4242,6 +4316,32 @@ const TOOLS = [
4242
4316
  required: ["project_id"],
4243
4317
  },
4244
4318
  },
4319
+ {
4320
+ name: "list_blocked_stories",
4321
+ description:
4322
+ "List stories with an unverified dependency — a story they depend on, or a story in an " +
4323
+ "epic their epic depends on — each with the blocking dependencies. It is by dependency " +
4324
+ "alone, WHATEVER the story's own status: an in-flight or finished story whose " +
4325
+ "prerequisite was later rejected is listed too. The rows carry no delivery stage: the " +
4326
+ "ones the dispatch driver skips are those whose story_stage is queued (check with " +
4327
+ "story_stage), and place_dispatch refuses them 409 dependencies_not_met. " +
4328
+ "Compact; paginated (page/page_size) with total_count. Refuses 422 when project_id is " +
4329
+ "not a UUID. Key: agent role or higher.",
4330
+ inputSchema: {
4331
+ type: "object",
4332
+ properties: {
4333
+ project_id: {
4334
+ type: "string",
4335
+ description: "Optional: the UUID of a project to limit the list to.",
4336
+ },
4337
+ page: { type: "integer", description: "Page number (default 1)." },
4338
+ page_size: {
4339
+ type: "integer",
4340
+ description: "Stories per page (default 100, max 500).",
4341
+ },
4342
+ },
4343
+ },
4344
+ },
4245
4345
  {
4246
4346
  name: "get_story",
4247
4347
  description: "Get full details for a single story by ID.",
@@ -4263,7 +4363,11 @@ const TOOLS = [
4263
4363
  description:
4264
4364
  "Agent acknowledges a story's acceptance criteria to claim the contract. " +
4265
4365
  "Transitions the story from pending to contracted. " +
4266
- "story_title and ac_count must match the actual story to prevent silent misclaims.",
4366
+ "story_title and ac_count must match the actual story to prevent silent misclaims. " +
4367
+ "Refused 409 story_held when the story's delivery stage is escalated, done or failed: " +
4368
+ "it is not available to agents even when it reads pending — move on. An escalated " +
4369
+ "story becomes available again only after resolve_escalation sends it to queued; a " +
4370
+ "done or failed one never does.",
4267
4371
  inputSchema: {
4268
4372
  type: "object",
4269
4373
  properties: {
@@ -4290,7 +4394,12 @@ const TOOLS = [
4290
4394
  "Transitions contracted -> assigned. Uses the AGENT key. On a loopctl with claim leases " +
4291
4395
  "the result leads with the claim's claim_epoch and claimed_until: keep the epoch, and " +
4292
4396
  "renew with renew_story_claim before claimed_until (default lease 24 hours) or the story " +
4293
- "is released back to pending under you.",
4397
+ "is released back to pending under you. Refused 409 story_held when the story's " +
4398
+ "delivery stage is escalated, done or failed: it is not yours to claim even when it " +
4399
+ "reads pending or contracted — move on. An escalated story is claimable again only " +
4400
+ "after resolve_escalation sends it to queued; a done or failed one never is. Refused " +
4401
+ "409 dependencies_not_met when a story it depends on, or one in an epic its epic " +
4402
+ "depends on, is not verified: list_blocked_stories names them.",
4294
4403
  inputSchema: {
4295
4404
  type: "object",
4296
4405
  properties: {
@@ -4306,12 +4415,22 @@ const TOOLS = [
4306
4415
  name: "renew_story_claim",
4307
4416
  description:
4308
4417
  "Renew your claim's lease (POST /api/v1/stories/:id/renew-claim): claimed_until becomes " +
4309
- "now plus the lease length, measured from NOW. Call it well inside the lease on any story " +
4310
- "you hold longer than it. Uses the AGENT key, the same key as claim_story. Refusals pass " +
4418
+ "now plus the lease length (default 24 hours), measured from NOW. Call it well inside the " +
4419
+ "lease on any story you hold longer than it. A DRIVER-PLACED claim (one a placement took " +
4420
+ "for a runner dispatch) is CAPPED AT ITS DISPATCH DEADLINE (claim_lease_cap: " +
4421
+ "placed_at + wall_clock_seconds + DISPATCH_LEASE_GRACE_SECONDS at the claim, which is " +
4422
+ "the dispatch's deadline_at; moved forward to a resume's time or the runner's " +
4423
+ "acceptance + wall_clock_seconds + the grace, only while the claim is live; never " +
4424
+ "earlier than a deadline_at already sent), and its claimed_until already IS that cap: " +
4425
+ "renewing it writes nothing and returns the claim as it stands, so renewing " +
4426
+ "never moves claimed_until past that instant. " +
4427
+ "Uses the AGENT key, the same key as claim_story. Refusals pass " +
4311
4428
  "through: 400 claim_epoch missing or not a non-negative integer; 422 not_claimed (the " +
4312
4429
  "story is not assigned or implementing); 409 stale_claim_epoch (the claim has ENDED: it " +
4313
4430
  "expired and was reclaimed, released, or claimed again, so stop working it); 409 " +
4314
- "not_claimant (your key's agent is not the story's assigned agent).",
4431
+ "not_claimant (your key's agent is not the story's assigned agent); " +
4432
+ "409 lease_cap_reached (a driver-placed claim already at its cap: nothing is renewed " +
4433
+ "and the claim ends there, so stop working it).",
4315
4434
  inputSchema: {
4316
4435
  type: "object",
4317
4436
  properties: {
@@ -4527,7 +4646,11 @@ const TOOLS = [
4527
4646
  name: "reject_story",
4528
4647
  description:
4529
4648
  "Orchestrator rejects a story with a reason. " +
4530
- "Creates a verification_result with result=fail. Uses the ORCH key.",
4649
+ "Creates a verification_result with result=fail. Uses the ORCH key. " +
4650
+ "The reject releases the claim, and a delivery story may be re-contracted or escalated " +
4651
+ "in the same transaction. A 500 `audit_chain_append_failed` (the escalation's chain " +
4652
+ "entry was refused) means the WHOLE call rolled back: the story is unchanged, and a " +
4653
+ "re-run meets the same server-side condition until an operator acts.",
4531
4654
  inputSchema: {
4532
4655
  type: "object",
4533
4656
  properties: {
@@ -7954,6 +8077,11 @@ const TOOLS = [
7954
8077
  "no_conforming_branch, and when a machine refuses dispatches with branch_not_allowed — a " +
7955
8078
  "runner that ENFORCES a prefix and declares none shows [] here, which is that " +
7956
8079
  "misconfiguration made visible. Per-CONNECTION, like kinds. " +
8080
+ "usage_exhausted_until (runner contract 1.17.0) is until when the machine's SUBSCRIPTION " +
8081
+ "is exhausted, or null: while it is in the future nothing is placed on the machine and " +
8082
+ "place_dispatch refuses 409 runner_exhausted. It is the EFFECTIVE value — the latest of " +
8083
+ "the machine's own and every machine's sharing its account — so a machine that never " +
8084
+ "reported anything can show one; capacity returns at that instant on its own. " +
7957
8085
  "live_sockets " +
7958
8086
  "above 1 means more than one process holds that runner's credential. A killed runner " +
7959
8087
  "disappears once its socket closes. Presence converges only " +
@@ -7969,7 +8097,8 @@ const TOOLS = [
7969
8097
  "push the work (POST /api/v1/runners/:runner_id/dispatches). This is the control-side " +
7970
8098
  "trigger of the agent delivery loop — the verb that turns a story the loop has decided " +
7971
8099
  "to build into a session running on a machine. Get `runner_id` and its free slots from " +
7972
- "runner_pool; the story must be `contracted` with its delivery stage at `queued` " +
8100
+ "runner_pool; the story must be `pending` or `contracted` (a pending one is contracted " +
8101
+ "by the placement), its dependencies met, with its delivery stage at `queued` " +
7973
8102
  "(story_stage shows where it is).\n\n" +
7974
8103
  "The STORY OBJECT is not a parameter: loopctl builds it from its own rows and refuses a " +
7975
8104
  "caller-supplied one, because a control plane able to hand a runner prose is able to " +
@@ -7988,6 +8117,11 @@ const TOOLS = [
7988
8117
  "choosing a machine. It refuses a NEW placement only: retrying with a dispatch_id " +
7989
8118
  "loopctl already holds still re-sends that dispatch, because its claim is already " +
7990
8119
  "standing and a draining machine is asked to finish what it holds, not to take more.\n\n" +
8120
+ "409 `runner_exhausted` means the machine's subscription (its own, or one it shares an " +
8121
+ "account with) is exhausted, so every session placed there would end usage_exhausted. " +
8122
+ "NOTHING WAS CLAIMED; the body carries `usage_exhausted_until`, when it clears on its " +
8123
+ "own. Place on another runner — runner_pool shows `usage_exhausted_until` per machine. " +
8124
+ "Like `runner_declines_work` it refuses a NEW placement only.\n\n" +
7991
8125
  "409 `no_conforming_branch` means the machine declares `branch_prefixes` (runner " +
7992
8126
  "contract 1.14.0) and none of them can produce a valid branch name carrying the story " +
7993
8127
  "number and id fragment that keeps two stories off one branch. NOTHING WAS CLAIMED. " +
@@ -8006,7 +8140,15 @@ const TOOLS = [
8006
8140
  "push and re-sent verbatim, because a session may be running on it right now. Neither " +
8007
8141
  "prefix refusal can be raised by a RETRY carrying a dispatch_id loopctl already " +
8008
8142
  "holds: its claim is already standing, so a declaration that changed under you only " +
8009
- "steers the name and never strands the story.",
8143
+ "steers the name and never strands the story.\n\n" +
8144
+ "A RETRY also moves the claim's deadline: the re-sent dispatch carries deadline_at = now + " +
8145
+ "its wall_clock_seconds + DISPATCH_LEASE_GRACE_SECONDS (never earlier than the one already " +
8146
+ "sent), so a late or a longer retry gets its whole clock. 409 `dispatch_claim_ended` means " +
8147
+ "the claim that dispatch_id was placed under has ENDED — its lease ran out, or the story " +
8148
+ "left assigned/implementing: nothing was pushed or written and the claim is not revived. " +
8149
+ "Place the story again with a NEW dispatch_id once it is placeable. 409 " +
8150
+ "`dependencies_not_met` means a story it depends on, or one in an epic its epic depends on, " +
8151
+ "is not verified: nothing was minted, claimed or pushed; place it once they are.",
8010
8152
  inputSchema: {
8011
8153
  type: "object",
8012
8154
  properties: {
@@ -8117,6 +8259,46 @@ const TOOLS = [
8117
8259
  required: ["story_id", "to"],
8118
8260
  },
8119
8261
  },
8262
+ {
8263
+ name: "merge_precondition",
8264
+ description:
8265
+ "RUN THE MERGE GATE over a story's real pull request " +
8266
+ "(POST /api/v1/stories/:id/merge-precondition). Both delivery gates run again over the " +
8267
+ "diff that exists, plus custody (`verified_status: verified` by a separate lineage), the " +
8268
+ "12-file / 1000-line hard bound, the head-has-not-moved check and the self-deploy " +
8269
+ "exclusion. The story must be at stage `ci`.\n\n" +
8270
+ "GATE A READS WHAT TRIAGE PERSISTED, NEVER YOU. Its input is the lens verdicts recorded " +
8271
+ "with the story's most recent triage, or a human's re-queue of a Gate A escalation; this " +
8272
+ "tool sends no trio. `gate_a_inputs` on the answer says which (`persisted_triage`, " +
8273
+ "`human_resolution`, or `missing` — which refuses with `gate_a_inputs_missing`).\n\n" +
8274
+ "DECISIONS: `allow` (recorded against the head, the only thing that licenses a merge), " +
8275
+ "`refuse` (the story is escalated before this returns), `already_merged`, `head_moved` " +
8276
+ "(back to implementing), `unevaluated` (503 with Retry-After: a transient forge fault; " +
8277
+ "retry after the delay, nothing transitioned).\n\n" +
8278
+ "REFUSALS. Needs an ORCHESTRATOR- or USER-role key: the action is `exact_role: " +
8279
+ "[:orchestrator, :user]`, so an agent key is 403'd. LOOPCTL_ORCH_KEY is sent when set, " +
8280
+ "else LOOPCTL_API_KEY. 403 `custody_tier_required` on a tenant without a human anchor, " +
8281
+ "404 for an unknown story, 422 when `claim_epoch` is missing or the story is not at `ci` " +
8282
+ "or has no stage row. A `story_id` that is not a UUID or a bad `claim_epoch` is refused " +
8283
+ "here, before any call.",
8284
+ inputSchema: {
8285
+ type: "object",
8286
+ properties: {
8287
+ story_id: { type: "string", description: "The story UUID, at stage `ci`." },
8288
+ claim_epoch: {
8289
+ type: "integer",
8290
+ description: "The claim epoch you act under; it fences the escalation a refusal writes.",
8291
+ },
8292
+ effect_proof: {
8293
+ type: "object",
8294
+ description:
8295
+ "Optional effect proof for a change touching an effect path. Judged and recorded; " +
8296
+ "it cannot turn a `prove_effect` outcome into an allow.",
8297
+ },
8298
+ },
8299
+ required: ["story_id", "claim_epoch"],
8300
+ },
8301
+ },
8120
8302
  {
8121
8303
  name: "force_unclaim_story",
8122
8304
  description:
@@ -8124,12 +8306,13 @@ const TOOLS = [
8124
8306
  "Two things happen: the story resets to `agent_status: pending` with " +
8125
8307
  "`assigned_agent_id` cleared, AND — in the same transaction — its delivery stage row " +
8126
8308
  "follows the release back to `queued`.\n\n" +
8127
- "IT FREES THE STAGE; IT DOES NOT MAKE THE STORY PLACEABLE. place_dispatch wants " +
8128
- "`agent_status: contracted` AND stage `queued` (`Placement.claimable/2`), and the " +
8129
- "release leaves the story at `pending`, whose only transition is `pending -> " +
8130
- "contracted`. Run place_dispatch straight after this one and you get back the IDENTICAL " +
8131
- "409 `invalid_transition`. THE REMEDY IS THREE CALLS, in this order: " +
8132
- "force_unclaim_story, then contract_story, then place_dispatch.\n\n" +
8309
+ "A DELIVERY STORY THEN GOES TO `escalated`, NOT BACK TO THE QUEUE (loopctl US-44.4). An " +
8310
+ "operator taking a story back is a human decision, so when the release leaves the stage " +
8311
+ "row at `queued` loopctl escalates it over `operator_released` in the same transaction — " +
8312
+ "it is not left in the queue behind the human's back. It spends no attempt " +
8313
+ "against the retry ceiling. To put it back to work, call resolve_escalation with " +
8314
+ "`to: queued`: that releases (a no-op now) AND re-contracts, so the story is placeable " +
8315
+ "again. A story with no delivery stage row is simply left `pending`, as before.\n\n" +
8133
8316
  "WHEN TO REACH FOR IT. A story sitting at `claimed` with nobody on it is the residue of " +
8134
8317
  "a compensation that did not complete — it is NOT what a refused dispatch normally " +
8135
8318
  "leaves. Placement answers a runner's refusal INLINE by releasing the claim itself " +
@@ -8150,11 +8333,18 @@ const TOOLS = [
8150
8333
  "A REGISTERED AGENT — an unlinked one is refused 400 naming that — and the tenant must be " +
8151
8334
  "human-anchored (403 `custody_tier_required` otherwise). 404 for an unknown story, 429 " +
8152
8335
  "when rate limited. A `story_id` that is not a UUID is refused here, before any call.\n\n" +
8153
- "It does NOT touch `verified_status`, and it is safe to run twice: on an already-pending " +
8154
- "story the stage row is written only when it needs to be — a row STRANDED behind the " +
8155
- "story's claim epoch is rebound to it (the remedy for a row an older release left " +
8156
- "behind) and an in-flight row is requeued, while a row already at that epoch is left " +
8157
- "exactly as it is. It takes no request body.",
8336
+ "It does NOT touch `verified_status`. RUN AGAIN on an already-pending story, it is still " +
8337
+ "an operator's release and does what one does: a row STRANDED behind the story's claim " +
8338
+ "epoch is rebound to it (the remedy for a row an older release left behind), an " +
8339
+ "in-flight row is requeued, and a row that is then at `queued` — including one that was " +
8340
+ "already sitting there — is ESCALATED over `operator_released`, like the first run. A " +
8341
+ "row already escalated, or anywhere else at that epoch, is left exactly as it is. EVERY " +
8342
+ "500 means the whole release rolled back and the story is still claimed: " +
8343
+ "`audit_chain_append_failed` (the escalation's chain entry was refused) is a " +
8344
+ "server-side condition a re-run meets again until an operator acts; " +
8345
+ "`force_unclaim_failed` (the " +
8346
+ "server log names the step) is remedied by calling it again. A 422 means the release " +
8347
+ "write itself was rejected. It takes no request body.",
8158
8348
  inputSchema: {
8159
8349
  type: "object",
8160
8350
  properties: {
@@ -8164,6 +8354,216 @@ const TOOLS = [
8164
8354
  },
8165
8355
  },
8166
8356
 
8357
+ {
8358
+ name: "intake_source_enroll",
8359
+ description:
8360
+ "BIND A GITHUB REPOSITORY TO A WORK PROJECT and mint its webhook secret (POST " +
8361
+ "/api/v1/intake/sources). This is how the agent delivery loop gets an input at all: " +
8362
+ "until a repository has an intake source, no webhook anywhere points at loopctl and no " +
8363
+ "issue can reach a queue. It is also the binding place_dispatch reads — a project bound " +
8364
+ "to no source refuses 409 `no_intake_source`, and one bound to two refuses 409 " +
8365
+ "`ambiguous_intake_source`; this tool is what those two refusals mean by enrolling a " +
8366
+ "source.\n\n" +
8367
+ "THE WEBHOOK SECRET IS RETURNED ONCE AND CAN NEVER BE READ AGAIN. The column is " +
8368
+ "encrypted at rest and redacted on the schema, so neither intake_source_list nor " +
8369
+ "intake_source_update ever carries it; losing it costs a revoke, a second enrolment and " +
8370
+ "a reconfigured GitHub webhook. THIS TOOL DOES NOT RETURN IT EITHER — a tool result " +
8371
+ "lands in the session transcript and the audit log, so the secret is written to " +
8372
+ "`secret_file` with mode 0600 (the same handling runner_enroll gives a runner " +
8373
+ "credential) and the result carries only the source row, the `webhook_url` and that " +
8374
+ "path. The path is reserved before the request, so an existing file or an unwritable " +
8375
+ "directory is refused while there is still nothing to lose, and any outcome that is not " +
8376
+ "a clean creation withholds the response body because an unparsed 2xx body IS the " +
8377
+ "secret. A 2xx that carries the source and the secret but no webhook path is RECOVERED " +
8378
+ "rather than discarded — the path is derived from the source id and the result says so " +
8379
+ "with `webhook_url_derived` — while an outcome that proves a source id but no usable " +
8380
+ "secret REVOKES that source, since it could never authenticate a delivery and would " +
8381
+ "hold the repository's unique slot.\n\n" +
8382
+ "THEN CONFIGURE GITHUB — the tool stops at the loopctl end and this is the other half. " +
8383
+ "On the repository: Settings > Webhooks > Add webhook, Payload URL = the `webhook_url` " +
8384
+ "returned, Content type = application/json, Secret = the contents of `secret_file`, and " +
8385
+ "under 'Let me select individual events' tick ISSUES ONLY. The secret is the HMAC key " +
8386
+ "GitHub signs the RAW body with as `X-Hub-Signature-256`; loopctl verifies it before " +
8387
+ "trusting a byte. In one command, without the secret ever passing through this " +
8388
+ "transcript: gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f " +
8389
+ "config[content_type]=json -f config[secret]=\"$(cat <secret_file>)\" -f 'events[]=issues'\n\n" +
8390
+ "EVERY delivery failure afterwards is the SAME 401 `invalid_signature` — an unknown or " +
8391
+ "revoked source, a suspended tenant, a missing or wrong signature, and a payload whose " +
8392
+ "`repository.full_name` does not match this source's repository. That is deliberate (a " +
8393
+ "valid signature for the wrong repository is a misrouted or replayed delivery), and it " +
8394
+ "means the webhook's own Recent Deliveries tab cannot tell you which of them happened.\n\n" +
8395
+ "REFUSALS. 403 `api_key_mint_forbidden` if your key was minted by a dispatch: this call " +
8396
+ "mints a credential belonging to no lineage, so only a key that is itself unlineaged — " +
8397
+ "the tenant's operator key in LOOPCTL_USER_KEY — may make it, and reaching for a more " +
8398
+ "privileged ephemeral key does not get past it. 403 `custody_tier_required` on an " +
8399
+ "agent-rooted tenant, which may not open an intake surface for itself (an intake source " +
8400
+ "admits outside text into the queue of a project whose stories an implementer will act " +
8401
+ "on). 422 when the repository is not `owner/name`, when an ACTIVE source already binds " +
8402
+ "that repository (revoke it first — the uniqueness is partial on not-yet-revoked), when " +
8403
+ "the project is missing, archived or not a work project, or when `target_epic_id` names " +
8404
+ "an epic outside that project. 429 when rate limited.\n\n" +
8405
+ "NAME `base_branch` AT ENROLMENT for a repository whose trunk is not `master`. It is the " +
8406
+ "branch every dispatch for this repository is cut FROM, it defaults to `master` when the " +
8407
+ "body does not name it, and GitHub has created repositories with `main` since 2020 — so " +
8408
+ "an unnamed branch on a `main` repository sends every dispatch to cut from a branch that " +
8409
+ "does not exist, and the failure arrives after the claim. There is no cleared state for " +
8410
+ "it (a dispatch must name one), so null or blank is refused rather than falling back to " +
8411
+ "the default; intake_source_update changes it afterwards.",
8412
+ inputSchema: {
8413
+ type: "object",
8414
+ properties: {
8415
+ repo_full_name: {
8416
+ type: "string",
8417
+ description:
8418
+ "The repository as `owner/name`, e.g. `mkreyman/home_care_billing`. Not a URL. " +
8419
+ "Matched case-insensitively against each delivery's `repository.full_name`.",
8420
+ },
8421
+ project_id: {
8422
+ type: "string",
8423
+ description:
8424
+ "The ACTIVE WORK project (UUID) whose queue this repository's issues feed. A `kb` " +
8425
+ "scope or an archived project is a 422.",
8426
+ },
8427
+ target_epic_id: {
8428
+ type: "string",
8429
+ description:
8430
+ "Optional. The epic a story triaged from this source's issues is created in; it " +
8431
+ "must belong to the project above. OMITTING IT IS NOT A NEUTRAL DEFAULT: with no " +
8432
+ "epic, a triaged report is ESCALATED to a human instead of becoming a story, and " +
8433
+ "every record from this source stays `pending_triage` and is retried until " +
8434
+ "intake_source_update names one — at which point they all promote on the next run " +
8435
+ "with nothing lost. Unanswered is the safe state rather than a guess.",
8436
+ },
8437
+ base_branch: {
8438
+ type: "string",
8439
+ description:
8440
+ "Optional. The branch every dispatch for this repository is cut FROM, and the " +
8441
+ "`base_branch` an unattended dispatch carries. Omit it for `master`; send `main` " +
8442
+ "for a repository created on GitHub since 2020, or the loop places work against a " +
8443
+ "branch that does not exist. NOT nullable, unlike target_epic_id — there is no " +
8444
+ "unanswered state for a branch a dispatch must name — so null or blank is refused " +
8445
+ "here rather than silently taking the default. Changed later with " +
8446
+ "intake_source_update.",
8447
+ },
8448
+ secret_file: {
8449
+ type: "string",
8450
+ description:
8451
+ "REQUIRED. Absolute path (or starting with `~/`) the webhook secret is written to, " +
8452
+ "mode 0600. It is never returned in the result. Refused if the path already " +
8453
+ "exists, and nothing is enrolled in that case.",
8454
+ },
8455
+ },
8456
+ required: ["repo_full_name", "project_id", "secret_file"],
8457
+ },
8458
+ },
8459
+ {
8460
+ name: "intake_source_list",
8461
+ description:
8462
+ "LIST THE TENANT'S GITHUB INTAKE SOURCES (GET /api/v1/intake/sources): per source its " +
8463
+ "`id` (the webhook URL is /api/v1/intake/github/<id>), `project_id`, `repo_full_name`, " +
8464
+ "`base_branch`, `target_epic_id`, `revoked_at` and timestamps. Active sources only " +
8465
+ "unless `include_revoked` is true.\n\n" +
8466
+ "THE WEBHOOK SECRET IS NOT HERE AND IS NOT ANYWHERE. It is returned once by " +
8467
+ "intake_source_enroll and the column is redacted on the schema, so this is not the way " +
8468
+ "to recover one — revoking and enrolling again is. Use this to find the source behind a " +
8469
+ "409 `no_intake_source` (the project is bound to none) or a 409 " +
8470
+ "`ambiguous_intake_source` (bound to two, so revoke the one that no longer applies), to " +
8471
+ "check a repository's `base_branch` before placing work, and to identify a source left " +
8472
+ "behind by an enrolment whose outcome was unknown. Requires LOOPCTL_USER_KEY; this read " +
8473
+ "is the one action of the four that does not need a human-anchored tenant.",
8474
+ inputSchema: {
8475
+ type: "object",
8476
+ properties: {
8477
+ include_revoked: {
8478
+ type: "boolean",
8479
+ description: "Include revoked sources. Default false.",
8480
+ },
8481
+ },
8482
+ },
8483
+ },
8484
+ {
8485
+ name: "intake_source_update",
8486
+ description:
8487
+ "SET WHERE A SOURCE'S WORK LANDS (PATCH /api/v1/intake/sources/:id): `target_epic_id`, " +
8488
+ "the epic triaged stories are created in, and `base_branch`, the branch every dispatch " +
8489
+ "for this repository is cut FROM and carries.\n\n" +
8490
+ "PRESENCE DECIDES, AND A FIELD YOU DO NOT NAME IS LEFT EXACTLY AS IT WAS. Naming " +
8491
+ "neither is refused 422 `nothing_to_update` rather than being a silent no-op. DO NOT " +
8492
+ "SEND `target_epic_id: null` TO MEAN 'I AM NOT CHANGING THIS' — an explicit null is the " +
8493
+ "only way to CLEAR the epic, and clearing it returns the source to escalating every " +
8494
+ "report to a human instead of filing a story. Leave the field out instead. " +
8495
+ "`base_branch` has no cleared state at all (every dispatch must name a branch to cut " +
8496
+ "from), so a null there is refused.\n\n" +
8497
+ "THIS IS THE FIX FOR A SOURCE ALREADY POINTED AT THE WRONG TRUNK. intake_source_enroll " +
8498
+ "now takes `base_branch` itself, so a `main` repository is enrolled correctly in one " +
8499
+ "call; this is what corrects one that was not — a source enrolled before the parameter " +
8500
+ "existed, or enrolled without it, places work against a trunk that does not exist until " +
8501
+ "it is repointed. It " +
8502
+ "is also the remedy for a source enrolled before it had an epic: until one is named " +
8503
+ "every record from it stays `pending_triage` and is retried, and the moment one is they " +
8504
+ "promote on the next run with nothing lost.\n\n" +
8505
+ "REVOKED SOURCES ARE 404, not a no-op: revoking CLEARS the target epic precisely so that " +
8506
+ "epic can be deleted, and repointing a source that will never report again would put " +
8507
+ "that block back. 422 when the epic is not in this source's project. Requires " +
8508
+ "LOOPCTL_USER_KEY and a human-anchored tenant (403 `custody_tier_required`). A " +
8509
+ "`source_id` that is not a UUID is refused here, before any call — the server answers " +
8510
+ "the same 404 for a malformed id as for an unknown one.",
8511
+ inputSchema: {
8512
+ type: "object",
8513
+ properties: {
8514
+ source_id: {
8515
+ type: "string",
8516
+ description: "The intake source UUID (from intake_source_list).",
8517
+ },
8518
+ target_epic_id: {
8519
+ type: ["string", "null"],
8520
+ description:
8521
+ "The epic triaged stories land in; it must belong to this source's project. An " +
8522
+ "explicit null CLEARS it, which returns the source to escalating every report. " +
8523
+ "Omit the field entirely to leave the current value alone.",
8524
+ },
8525
+ base_branch: {
8526
+ type: "string",
8527
+ description:
8528
+ "The branch dispatches for this repository are cut from, e.g. `main`. 1-255 " +
8529
+ "characters, not nullable. Omit to leave the current value alone.",
8530
+ },
8531
+ },
8532
+ required: ["source_id"],
8533
+ },
8534
+ },
8535
+ {
8536
+ name: "intake_source_revoke",
8537
+ description:
8538
+ "REVOKE A GITHUB INTAKE SOURCE (DELETE /api/v1/intake/sources/:id). The HTTP verb is " +
8539
+ "DELETE and the action is a REVOKE: `revoked_at` is stamped, the row is kept, and " +
8540
+ "intake_source_list still returns it with `include_revoked`. Nothing already received " +
8541
+ "is discarded.\n\n" +
8542
+ "Afterwards every delivery to that webhook URL is refused 401 `invalid_signature`, " +
8543
+ "exactly as a wrong secret is — so the GitHub webhook does not know it has been cut off " +
8544
+ "and should be deleted on the repository too. IDEMPOTENT: revoking twice answers 200 " +
8545
+ "with the original `revoked_at`.\n\n" +
8546
+ "TWO THINGS IT UNBLOCKS. It frees the repository's uniqueness slot, so a source with a " +
8547
+ "lost secret, a wrong project or a typo'd repository is corrected by revoking and " +
8548
+ "enrolling again — the active-repository index is partial on not-yet-revoked, so the " +
8549
+ "422 `an active intake source already binds this repository` clears the moment this " +
8550
+ "returns. And it CLEARS `target_epic_id`, which is what makes that epic deletable: the " +
8551
+ "reference blocks an epic delete, and this is the remedy that refusal names.\n\n" +
8552
+ "Requires LOOPCTL_USER_KEY and a human-anchored tenant (403 `custody_tier_required`). " +
8553
+ "404 for an unknown source or another tenant's; a `source_id` that is not a UUID is " +
8554
+ "refused here, before any call, because the server answers that same 404 for both.",
8555
+ inputSchema: {
8556
+ type: "object",
8557
+ properties: {
8558
+ source_id: {
8559
+ type: "string",
8560
+ description: "The intake source UUID to revoke (from intake_source_list).",
8561
+ },
8562
+ },
8563
+ required: ["source_id"],
8564
+ },
8565
+ },
8566
+
8167
8567
  // LCP-1 §9 signed-profile tools
8168
8568
  {
8169
8569
  name: "register_custody_owner_key",
@@ -8895,6 +9295,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8895
9295
  case "list_ready_stories":
8896
9296
  return await listReadyStories(args);
8897
9297
 
9298
+ case "list_blocked_stories":
9299
+ return await listBlockedStories(args);
9300
+
8898
9301
  case "get_story":
8899
9302
  return await getStory(args);
8900
9303
 
@@ -9206,6 +9609,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
9206
9609
  case "force_unclaim_story":
9207
9610
  return await forceUnclaimStory(args);
9208
9611
 
9612
+ case "merge_precondition":
9613
+ return await mergePreconditionTool(args);
9614
+
9615
+ case "intake_source_enroll":
9616
+ return await intakeSourceEnroll(args);
9617
+
9618
+ case "intake_source_list":
9619
+ return await intakeSourceList(args);
9620
+
9621
+ case "intake_source_update":
9622
+ return await intakeSourceUpdate(args);
9623
+
9624
+ case "intake_source_revoke":
9625
+ return await intakeSourceRevoke(args);
9626
+
9209
9627
  case "register_custody_owner_key":
9210
9628
  return await registerCustodyOwnerKey(args);
9211
9629
 
@@ -2,7 +2,9 @@
2
2
  * Story claim leases (loopctl #803/#810, MCP side under #809).
3
3
  *
4
4
  * A claim now carries a LEASE (`claimed_until`, default 24h, STORY_CLAIM_LEASE_SECONDS)
5
- * and a FENCE (`claim_epoch`, bumped by every claim and every release). A claim that is not
5
+ * and a FENCE (`claim_epoch`, bumped by every claim and every release). A claim a placement
6
+ * took for a runner dispatch also carries `claim_lease_cap` (#879): its dispatch deadline,
7
+ * which no renewal moves the lease past. A claim that is not
6
8
  * renewed before `claimed_until` is released back to `pending` under the agent still
7
9
  * working it. `renew_story_claim` is the MCP path to `POST /stories/:id/renew-claim`, and
8
10
  * `claimLeaseNotice` puts the epoch and the deadline at the top of a `claim_story` result so
@@ -13,8 +15,9 @@
13
15
  * travels on is testable behaviour rather than a source pattern.
14
16
  *
15
17
  * Nothing is validated client-side beyond `story_id`: the server's 400 (missing or
16
- * non-integer epoch), 422 `not_claimed`, 409 `stale_claim_epoch` and 409 `not_claimant`
17
- * pass through unchanged, because each one tells the agent something different to do.
18
+ * non-integer epoch), 422 `not_claimed`, 409 `stale_claim_epoch`, 409 `not_claimant` and
19
+ * 409 `lease_cap_reached` pass through unchanged, because each one tells the agent
20
+ * something different to do.
18
21
  */
19
22
 
20
23
  export function renewClaimPath(storyId) {
@@ -45,8 +48,14 @@ export function claimLeaseNotice(result) {
45
48
  "story is released back to pending under you."
46
49
  : "no lease (claimed_until is null), so it is never released automatically.";
47
50
 
51
+ // #879: a driver-placed claim is capped at its dispatch deadline, and no renewal passes it.
52
+ const cap = story.claim_lease_cap
53
+ ? ` This claim is CAPPED at its dispatch deadline ${story.claim_lease_cap}: renewing never ` +
54
+ "moves claimed_until past it."
55
+ : "";
56
+
48
57
  return (
49
- `CLAIM LEASE: claim_epoch ${story.claim_epoch}, ${lease} Keep claim_epoch; ` +
58
+ `CLAIM LEASE: claim_epoch ${story.claim_epoch}, ${lease}${cap} Keep claim_epoch; ` +
50
59
  "renew_story_claim requires it. A 409 stale_claim_epoch means the claim has ended: stop working it."
51
60
  );
52
61
  }