@mrrlin-dev/mcp 0.4.0 → 0.4.2

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.
Files changed (2) hide show
  1. package/dist/bin.cjs +317 -100
  2. package/package.json +5 -5
package/dist/bin.cjs CHANGED
@@ -36476,6 +36476,7 @@ var openApiOperationIds = {
36476
36476
  getOperatorSettings: "getOperatorSettings",
36477
36477
  updateOperatorSettings: "updateOperatorSettings",
36478
36478
  recordOperatorPresence: "recordOperatorPresence",
36479
+ pauseAutopilotForLimit: "pauseAutopilotForLimit",
36479
36480
  getAgentPool: "getAgentPool",
36480
36481
  putAgentPool: "putAgentPool",
36481
36482
  getConsensusConfig: "getConsensusConfig",
@@ -36522,6 +36523,13 @@ var mrrlinOperatorSettingsSchema = external_exports.object({
36522
36523
  activeProjectSlug: external_exports.string().min(1).nullable(),
36523
36524
  // The last time an authenticated BROWSER operator session was observed. Null means never seen.
36524
36525
  lastPresenceAt: external_exports.string().datetime().nullable(),
36526
+ // autopilot-limit-pause: READ-ONLY status for an automatic provider-limit pause. These are never
36527
+ // writable via mrrlinOperatorSettingsUpdateSchema (an explicit operator write clears them
36528
+ // server-side). Non-null autoPauseSource => the current pause is machine-owned; autoPauseResetAt
36529
+ // is the absolute reset time (null = indefinite, manual resume required).
36530
+ autoPauseSource: external_exports.string().min(1).nullable(),
36531
+ autoPauseReason: external_exports.string().min(1).nullable(),
36532
+ autoPauseResetAt: external_exports.string().datetime().nullable(),
36525
36533
  createdAt: external_exports.string().datetime(),
36526
36534
  updatedAt: external_exports.string().datetime()
36527
36535
  });
@@ -36533,6 +36541,11 @@ var mrrlinOperatorSettingsUpdateSchema = external_exports.object({
36533
36541
  message: "OperatorSettingsUpdate requires at least one field"
36534
36542
  });
36535
36543
  var operatorSettingsResponseSchema = external_exports.object({ data: mrrlinOperatorSettingsSchema });
36544
+ var autopilotLimitPauseRequestSchema = external_exports.object({
36545
+ source: external_exports.string().min(1).default("provider_limit"),
36546
+ reason: external_exports.string().min(1).max(500),
36547
+ resetAt: external_exports.string().datetime().nullable().default(null)
36548
+ }).strict();
36536
36549
  var operatorPresenceResponseSchema = external_exports.object({ data: external_exports.object({ lastPresenceAt: external_exports.string().datetime() }) });
36537
36550
  var operatorPresenceRequestSchema = external_exports.object({ projectSlug: external_exports.string().min(1).optional() }).strict().optional();
36538
36551
  var mrrlinConsensusConfigSchema = external_exports.object({ consensusEnabled: external_exports.boolean() });
@@ -38123,7 +38136,7 @@ var wakeDirectorResponseSchema = external_exports.object({
38123
38136
  }))
38124
38137
  })
38125
38138
  });
38126
- var mrrlinInboxItemKindSchema = external_exports.enum(["proposal", "check_in", "question", "handoff", "info"]);
38139
+ var mrrlinInboxItemKindSchema = external_exports.enum(["proposal", "check_in", "question", "handoff", "info", "completed"]);
38127
38140
  var mrrlinInboxItemStatusSchema = external_exports.enum(["pending", "approved", "rejected", "acknowledged"]);
38128
38141
  var mrrlinInboxItemIdSchema = external_exports.string().regex(/^II-[A-Za-z0-9._-]+$/);
38129
38142
  var mrrlinInboxItemActionPayloadSchema = external_exports.object({
@@ -38174,6 +38187,9 @@ var mrrlinInboxQuestionOptionsSchema = external_exports.array(mrrlinInboxQuestio
38174
38187
  });
38175
38188
  }
38176
38189
  });
38190
+ var REVIEW_AGAIN_ANSWER = "Review the current spec again without changing it.";
38191
+ var EDIT_SPEC_ANSWER = "Hold the task until the spec is edited by hand.";
38192
+ var PROCEED_ANYWAY_ANSWER = "Proceed without agreement; record the unsettled points on the spec.";
38177
38193
  var mrrlinInboxItemSchema = external_exports.object({
38178
38194
  actionPayload: mrrlinInboxItemActionPayloadSchema.nullable(),
38179
38195
  answer: external_exports.string().nullable(),
@@ -38583,6 +38599,36 @@ function addTurnTokenUsage(total, turn) {
38583
38599
  };
38584
38600
  }
38585
38601
 
38602
+ // ../../packages/codex-client/dist/rate-limit.js
38603
+ var EXHAUSTION_USED_PERCENT = 100;
38604
+ function windowExhausted(window) {
38605
+ if (!window)
38606
+ return false;
38607
+ return typeof window.usedPercent === "number" && Number.isFinite(window.usedPercent) && window.usedPercent >= EXHAUSTION_USED_PERCENT;
38608
+ }
38609
+ function normalizeResetEpochSeconds(resetsAt, nowMs) {
38610
+ if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt))
38611
+ return null;
38612
+ const resetMs = resetsAt * 1e3;
38613
+ if (resetMs <= nowMs)
38614
+ return null;
38615
+ return new Date(resetMs).toISOString();
38616
+ }
38617
+ function detectExhaustedProviderLimit(snapshot, nowMs) {
38618
+ if (!snapshot)
38619
+ return null;
38620
+ const reachedType = typeof snapshot.rateLimitReachedType === "string" ? snapshot.rateLimitReachedType.trim() : "";
38621
+ const exhausted = reachedType.length > 0 || windowExhausted(snapshot.primary) || windowExhausted(snapshot.secondary);
38622
+ if (!exhausted)
38623
+ return null;
38624
+ const candidates = [
38625
+ normalizeResetEpochSeconds(snapshot.primary?.resetsAt, nowMs),
38626
+ normalizeResetEpochSeconds(snapshot.secondary?.resetsAt, nowMs)
38627
+ ].filter((value) => value !== null);
38628
+ const resetAt = candidates.length > 0 ? candidates.reduce((a, b) => a > b ? a : b) : null;
38629
+ return { exhausted: true, resetAt };
38630
+ }
38631
+
38586
38632
  // ../../packages/client/dist/agent-token-provider.js
38587
38633
  var AgentTokenProviderError = class extends Error {
38588
38634
  code;
@@ -39493,6 +39539,15 @@ function createMrrlinClient(config2) {
39493
39539
  });
39494
39540
  return operatorSettingsResponseSchema.parse(body).data;
39495
39541
  },
39542
+ async pauseAutopilotForLimit(input) {
39543
+ const parsed = autopilotLimitPauseRequestSchema.parse(input);
39544
+ const body = await request(`/operator/autopilot/limit-pause`, {
39545
+ body: JSON.stringify(parsed),
39546
+ headers: { "content-type": "application/json" },
39547
+ method: "POST"
39548
+ });
39549
+ return operatorSettingsResponseSchema.parse(body).data;
39550
+ },
39496
39551
  async recordOperatorPresence(input) {
39497
39552
  const projectSlug = input?.projectSlug;
39498
39553
  const body = await request(`/operator/presence`, {
@@ -40135,7 +40190,7 @@ function startCodexProbe(deps = {}) {
40135
40190
  }
40136
40191
 
40137
40192
  // src/_generated/version.ts
40138
- var PKG_VERSION = "0.4.0";
40193
+ var PKG_VERSION = "0.4.2";
40139
40194
 
40140
40195
  // src/api-base-url-migrate.ts
40141
40196
  var import_node_fs2 = __toESM(require("node:fs"), 1);
@@ -41260,7 +41315,7 @@ async function runConsensus(input) {
41260
41315
  let decision;
41261
41316
  for (let round = 1; round <= effectiveRounds; round++) {
41262
41317
  if (calls + input.heads.length > input.maxHeadCalls) {
41263
- return { outcome: "CONSENSUS_BLOCKED", rounds: round - 1, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41318
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round - 1, finalArtifact: artifact, history, unresolved: history[history.length - 1]?.accepted ?? [], ...decision ? { escalation: decision } : {} };
41264
41319
  }
41265
41320
  const dismissedBlock = dismissedFwd.length ? `PREVIOUSLY DISMISSED (do not re-raise unless new information):
41266
41321
  ${dismissedFwd.slice(-20).join("\n")}` : "";
@@ -41282,7 +41337,7 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
41282
41337
  (r) => r.res.ok
41283
41338
  );
41284
41339
  if (responding.length === 0) {
41285
- return { outcome: "CONSENSUS_UNAVAILABLE", rounds: round, finalArtifact: artifact, history };
41340
+ return { outcome: "CONSENSUS_UNAVAILABLE", rounds: round, finalArtifact: artifact, history, unresolved: [] };
41286
41341
  }
41287
41342
  const headOutcomes = input.heads.map((persona) => ({
41288
41343
  persona,
@@ -41308,7 +41363,7 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
41308
41363
  let adj;
41309
41364
  if (issues.length > 0) {
41310
41365
  if (calls + 1 > input.maxHeadCalls) {
41311
- return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41366
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, unresolved: history[history.length - 1]?.accepted ?? [], ...decision ? { escalation: decision } : {} };
41312
41367
  }
41313
41368
  calls++;
41314
41369
  try {
@@ -41322,7 +41377,7 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
41322
41377
  history.push({ round, accepted: adj.accepted, dismissed: adj.dismissed, headOutcomes });
41323
41378
  adj.dismissed.forEach((d) => dismissedFwd.push(`- ${d.id}: ${d.reason}`));
41324
41379
  if (adj.accepted.length === 0) {
41325
- return { outcome: "CONVERGED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41380
+ return { outcome: "CONVERGED", rounds: round, finalArtifact: artifact, history, unresolved: [], ...decision ? { escalation: decision } : {} };
41326
41381
  }
41327
41382
  if (round === effectiveRounds) {
41328
41383
  if (!decision) {
@@ -41332,12 +41387,12 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
41332
41387
  escalated = true;
41333
41388
  effectiveRounds = Math.min(effectiveRounds + decision.grantedRounds, input.rounds * 2);
41334
41389
  } else {
41335
- return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, ...decision ? { escalation: decision } : {} };
41390
+ return { outcome: "CONSENSUS_BLOCKED", rounds: round, finalArtifact: artifact, history, unresolved: history[history.length - 1]?.accepted ?? [], ...decision ? { escalation: decision } : {} };
41336
41391
  }
41337
41392
  }
41338
41393
  artifact = await input.revise(artifact, adj.accepted);
41339
41394
  }
41340
- return { outcome: "CONSENSUS_BLOCKED", rounds: effectiveRounds, finalArtifact: artifact, history };
41395
+ return { outcome: "CONSENSUS_BLOCKED", rounds: effectiveRounds, finalArtifact: artifact, history, unresolved: history[history.length - 1]?.accepted ?? [] };
41341
41396
  }
41342
41397
 
41343
41398
  // src/consensus/spec-identity.ts
@@ -41636,7 +41691,8 @@ async function runSpecGate(_target, deps, opts) {
41636
41691
  outcome: "CONSENSUS_BLOCKED",
41637
41692
  ...keptClarifications.length > 0 ? { operatorClarifications: keptClarifications } : {},
41638
41693
  ...droppedByCap.length > 0 ? { droppedByCap } : {},
41639
- ...result.escalation ? { escalation: result.escalation } : {}
41694
+ ...result.escalation ? { escalation: result.escalation } : {},
41695
+ unresolved: result.unresolved
41640
41696
  };
41641
41697
  }
41642
41698
  return { outcome: result.outcome };
@@ -42946,7 +43002,7 @@ function onComplete(outcome, input) {
42946
43002
  }
42947
43003
  const taskRef = parsed.taskTitle ? `${input.taskId} \u2014 ${parsed.taskTitle}` : input.taskId;
42948
43004
  return {
42949
- kind: "info",
43005
+ kind: "completed",
42950
43006
  taskId: input.taskId,
42951
43007
  body: `Spec drafted automatically for ${taskRef} \u2014 page ${parsed.pageId ?? "?"} in Specs/. The next scheduler tick will run the consensus review.`
42952
43008
  };
@@ -44006,6 +44062,10 @@ function executionTurnBudgetMessage(budgetMs) {
44006
44062
  function isExecutionTurnTimeoutReason(reason) {
44007
44063
  return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX) || reason.startsWith(EXECUTION_TURN_STALL_PREFIX);
44008
44064
  }
44065
+ function reportAutopilotLimitPause(client, reason, resetAt) {
44066
+ void client.pauseAutopilotForLimit({ source: "provider_limit", reason, resetAt }).catch(() => {
44067
+ });
44068
+ }
44009
44069
  function isDeliverableGateRetryableReason(reason) {
44010
44070
  return DELIVERABLE_GATE_RETRYABLE_REASON_PREFIXES.some((prefix) => reason.startsWith(prefix));
44011
44071
  }
@@ -44085,17 +44145,16 @@ var BASE_INSTRUCTIONS_PREFIX = [
44085
44145
  " qualifies, produce the file; never merely offer one.",
44086
44146
  "- Mixed output counts as retrieved: if any material part came from a retrieval, treat the whole",
44087
44147
  " result as retrieved rather than splitting it between inline text and a file.",
44088
- "- When your output is a file (markdown report, HTML, screenshot), upload it with",
44089
- " `upload_artifact` and embed the returned markdown link \u2014 never inline large blobs",
44090
- " into chat, spec, or note text.",
44148
+ "- When your output is a file (markdown report, HTML), call `deliver_file`: pass the body inline",
44149
+ " as `content` and embed the returned markdown link. It needs no writable path, so it is the",
44150
+ " default even when this run path cannot write files \u2014 never inline large blobs into chat,",
44151
+ " spec, or note text.",
44091
44152
  "- class=temp for one-off operator hand-offs (48h); class=durable for anything a spec",
44092
44153
  " or evidence page will reference (180 days). Always pass taskId/runId when known.",
44093
- "- Anything user-facing delivered as a file MUST be uploaded. Files are capped at 5MB \u2014",
44094
- " split or summarize larger outputs.",
44095
- "- `upload_artifact` needs a real local file path, so it presupposes you can write one. When this",
44096
- " run path CAN write files, produce the artifact normally \u2014 that is the default. ONLY when it",
44097
- " cannot write, do NOT attempt the file: put the summary in your result text and state what a",
44098
- " file would have held. Never fake a path you did not write.",
44154
+ "- Anything user-facing delivered as a file MUST go through `deliver_file`. Files are capped at",
44155
+ " 5MB \u2014 split or summarize larger outputs.",
44156
+ "- Use `upload_artifact` only for a file already on disk (e.g. a screenshot the browser wrote);",
44157
+ " it needs a real local path, so never fake a path you did not write.",
44099
44158
  "",
44100
44159
  "Produced URLs \u2014 the operator's per-page links come only from you:",
44101
44160
  "- Call `record_produced_url` after publishing EACH web page this task produced, so the task UI",
@@ -44425,6 +44484,17 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
44425
44484
  }
44426
44485
  const usage = !notifThreadId || notifThreadId === threadId ? extractTurnTokenUsage(method, params) : null;
44427
44486
  if (usage) turnUsage = usage;
44487
+ if (method === "account/rateLimits/updated") {
44488
+ const snapshot = params?.rateLimits ?? null;
44489
+ const signal = detectExhaustedProviderLimit(snapshot, Date.now());
44490
+ if (signal) {
44491
+ reportAutopilotLimitPause(
44492
+ deps.client,
44493
+ "Codex provider limit exhausted during an autonomous execution run.",
44494
+ signal.resetAt
44495
+ );
44496
+ }
44497
+ }
44428
44498
  const events = mapper.mapNotification(
44429
44499
  { method, params },
44430
44500
  threadId
@@ -51060,10 +51130,11 @@ function buildThreadStartParams(msg, config2, desiredModel) {
51060
51130
  " treat the whole answer as retrieved rather than splitting it between chat and a file.",
51061
51131
  '- When it qualifies, PRODUCE the file. Offering one ("I can give you this as CSV/JSON") is the',
51062
51132
  " failure, not the courtesy.",
51063
- "- When it is a file: write it (`.md` for linear prose, `.html` when structure or layout",
51064
- " carries meaning), upload with `upload_artifact` (class=temp for one-off downloads,",
51065
- " class=durable when a spec/evidence page references it; pass taskId when known), and paste",
51066
- " the returned markdown link into your reply.",
51133
+ "- When it is a file: choose the format (`.md` for linear prose, `.html` when structure or",
51134
+ " layout carries meaning), then call `deliver_file` \u2014 pass the body inline as `content` and it",
51135
+ " needs no local file (class=temp for one-off downloads, class=durable when a spec/evidence",
51136
+ " page references it; pass taskId when known), and paste the returned markdown link into your",
51137
+ " reply. Use `upload_artifact` only for a file that already exists on disk (e.g. a screenshot).",
51067
51138
  "- The chat reply must still stand alone as a complete answer even if the file is never opened:",
51068
51139
  " the headline finding, the few points that matter, then the link. Never reduce it to",
51069
51140
  ' "see attached" or "details in the report".',
@@ -51300,6 +51371,10 @@ var DEFAULT_WORKTREE_RETENTION_HOURS = 48;
51300
51371
  function worktreeRetentionMs() {
51301
51372
  return readEnvInt("MRRLIN_DIRECTOR_WORKTREE_RETENTION_HOURS", DEFAULT_WORKTREE_RETENTION_HOURS) * 36e5;
51302
51373
  }
51374
+ function reportAutopilotLimitPause2(client, reason, resetAt) {
51375
+ void client.pauseAutopilotForLimit({ source: "provider_limit", reason, resetAt }).catch(() => {
51376
+ });
51377
+ }
51303
51378
  async function runOrphanWorktreeSweep(client, opts) {
51304
51379
  const cutoff = Date.now() - worktreeRetentionMs();
51305
51380
  const isReapable = (worktreePath) => {
@@ -52310,7 +52385,13 @@ function createBridgeMessageHandler(deps) {
52310
52385
  pokeActivity();
52311
52386
  if (method === "account/rateLimits/updated") {
52312
52387
  const snapshot = params?.rateLimits ?? null;
52313
- if (snapshot) sendInTurn({ type: "rateLimits", directorSessionId, rateLimits: snapshot });
52388
+ if (snapshot) {
52389
+ sendInTurn({ type: "rateLimits", directorSessionId, rateLimits: snapshot });
52390
+ const signal = detectExhaustedProviderLimit(snapshot, Date.now());
52391
+ if (signal) {
52392
+ deps.reportLimitPause?.("Codex provider limit exhausted during a Director turn.", signal.resetAt);
52393
+ }
52394
+ }
52314
52395
  }
52315
52396
  const events = mapper.mapNotification({ method, params }, threadId);
52316
52397
  for (const event of events) {
@@ -52412,6 +52493,10 @@ ${effectiveText}` : effectiveText;
52412
52493
  forward(event);
52413
52494
  }
52414
52495
  if (outputEvents === 0) {
52496
+ deps.reportLimitPause?.(
52497
+ "Director turn completed without output; Codex likely hit its usage or credits limit.",
52498
+ null
52499
+ );
52415
52500
  sendInTurn({
52416
52501
  type: "error",
52417
52502
  directorSessionId,
@@ -52909,6 +52994,7 @@ async function startDirectorBridge() {
52909
52994
  turnInactivity: () => ({ idleMs: chatTurnIdleMs(), maxMs: chatTurnMaxMs() }),
52910
52995
  checkoutRegistry,
52911
52996
  setCodexAuth: (s) => health.setCodexAuth(s),
52997
+ reportLimitPause: (reason, resetAt) => reportAutopilotLimitPause2(client, reason, resetAt),
52912
52998
  getPairingQr: async () => {
52913
52999
  if (!relayConfigured || !remoteRelay) return { status: "not-configured" };
52914
53000
  const payload = remoteRelay.qrPayload;
@@ -57383,32 +57469,49 @@ var HUMANIZE_TEMPLATE_BY_KIND = {
57383
57469
  missing_requirement: "A required part of the task is still missing from the spec. What should it say?",
57384
57470
  acceptance_criteria_detail: "The direction is clear, but the spec still needs a sharper success check. What should count as done?"
57385
57471
  };
57386
- var HUMANIZE_CONTEXT_CAP = 200;
57387
- function truncateToSentenceBoundary(text, cap) {
57388
- if (text.length <= cap) return text;
57389
- const sentences = text.match(/.+?[.!?](?=\s|$)/g);
57390
- if (sentences) {
57391
- let kept = "";
57392
- let keptCount = 0;
57393
- for (const sentence of sentences) {
57394
- const candidate = kept ? `${kept} ${sentence.trim()}` : sentence.trim();
57395
- if (candidate.length > cap) break;
57396
- kept = candidate;
57397
- keptCount++;
57398
- }
57399
- if (kept) return keptCount < sentences.length ? `${kept} \u2026` : kept;
57400
- }
57401
- const cutIdx = text.lastIndexOf(" ", cap);
57402
- return text.slice(0, cutIdx > 0 ? cutIdx : cap).trim() + "\u2026";
57403
- }
57404
57472
  function humanizeClarificationBody(clarification) {
57405
57473
  const prefix = HUMANIZE_TEMPLATE_BY_KIND[clarification.kind];
57406
- const context = truncateToSentenceBoundary(
57407
- clarification.rawQuestion.replace(/\b(?:parseable|SPEC_IDENTITY_MISMATCH|machine-usable)\b/gi, "").replace(/\s+/g, " ").trim(),
57408
- HUMANIZE_CONTEXT_CAP
57409
- );
57474
+ const context = clarification.rawQuestion.replace(/\b(?:parseable|SPEC_IDENTITY_MISMATCH|machine-usable)\b/gi, "").replace(/\s+/g, " ").trim();
57410
57475
  return context ? `${prefix} ${context}` : prefix;
57411
57476
  }
57477
+ var BLOCK_DECISION_OPENER = "The reviewers could not agree on this spec";
57478
+ var BLOCK_BODY_TRAILER = " How should I proceed?";
57479
+ function renderUnresolvedPoints(unresolved) {
57480
+ return unresolved.map(
57481
+ (issue2) => issue2.text.replace(/\b(?:parseable|SPEC_IDENTITY_MISMATCH|machine-usable)\b/gi, "").replace(/\s+/g, " ").trim()
57482
+ ).join("; ");
57483
+ }
57484
+ function renderBlockDecisionBody(taskRef, unresolved) {
57485
+ if (unresolved.length === 0) {
57486
+ return `${BLOCK_DECISION_OPENER} (task ${taskRef}), and their objections could not be read back.${BLOCK_BODY_TRAILER}`;
57487
+ }
57488
+ return `${BLOCK_DECISION_OPENER} (task ${taskRef}). Still unsettled: ${renderUnresolvedPoints(unresolved)}.${BLOCK_BODY_TRAILER}`;
57489
+ }
57490
+ function buildBlockDecisionOptions(diagnosis, reviewAgainAvailable) {
57491
+ const recommendReviewAgain = reviewAgainAvailable && (diagnosis === "INFRA" || diagnosis === "SUBSTANCE");
57492
+ const recommendEditSpec = !reviewAgainAvailable || diagnosis === "AMBIGUITY";
57493
+ const options = [];
57494
+ if (reviewAgainAvailable) {
57495
+ options.push({
57496
+ label: "Review again",
57497
+ description: "Send the current spec back for another review.",
57498
+ answer: REVIEW_AGAIN_ANSWER,
57499
+ ...recommendReviewAgain ? { recommended: true } : {}
57500
+ });
57501
+ }
57502
+ options.push({
57503
+ label: "Edit spec",
57504
+ description: "Open the spec and change it by hand; the review restarts on save.",
57505
+ answer: EDIT_SPEC_ANSWER,
57506
+ ...recommendEditSpec ? { recommended: true } : {}
57507
+ });
57508
+ options.push({
57509
+ label: "Proceed anyway",
57510
+ description: "Start the work with these points unsettled and recorded.",
57511
+ answer: PROCEED_ANYWAY_ANSWER
57512
+ });
57513
+ return options;
57514
+ }
57412
57515
  var defaultNativeSpecGateBuilders = { buildSpecGateDeps, runSpecGate };
57413
57516
  async function runNativeSpecGate(ctx, input, builders = defaultNativeSpecGateBuilders) {
57414
57517
  const deps = builders.buildSpecGateDeps({
@@ -57455,6 +57558,7 @@ function stripHumanizeOpener(body) {
57455
57558
  }
57456
57559
  return body;
57457
57560
  }
57561
+ var DID_NOT_CONVERGE_ERROR = "Consensus blocked: reviewers did not converge within budget.";
57458
57562
  async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57459
57563
  const operatorClarifications = result.operatorClarifications ?? [];
57460
57564
  const diagnostics = result.diagnostics ?? [];
@@ -57462,9 +57566,10 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57462
57566
  const taskId = page?.taskId ?? null;
57463
57567
  if (!resolvedTaskId && taskId) resolvedTaskId = taskId;
57464
57568
  const adjudicated = [];
57569
+ let taskInboxItems = [];
57465
57570
  if (taskId) {
57466
57571
  try {
57467
- const taskInboxItems = await ctx.client.listInboxItems(input.projectSlug, { taskId });
57572
+ taskInboxItems = await ctx.client.listInboxItems(input.projectSlug, { taskId });
57468
57573
  for (const item of taskInboxItems) {
57469
57574
  if (item.origin === "gate" && (item.status === "acknowledged" || item.status === "rejected")) {
57470
57575
  adjudicated.push({ body: stripHumanizeOpener(item.body), answer: item.answer, status: item.status });
@@ -57533,6 +57638,35 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57533
57638
  filedQuestionCount = toFile.length;
57534
57639
  }
57535
57640
  }
57641
+ if (filedQuestionCount === 0 && diagnostics.length === 0 && taskId && page) {
57642
+ const domain2 = await resolveSpecDomain(ctx.client, input.projectSlug, {
57643
+ id: page.id,
57644
+ taskId: page.taskId ?? null
57645
+ });
57646
+ const reviewHash = domainScopedReviewHash2(page.contentHash, domain2);
57647
+ const baseBlockItemId = `II-${taskId}-gateblock-${(0, import_node_crypto9.createHash)("sha256").update(reviewHash).digest("hex").slice(0, 10)}`;
57648
+ let taskRef = taskId;
57649
+ try {
57650
+ const t = await ctx.client.getTask(input.projectSlug, taskId);
57651
+ if (t?.title) taskRef = `${taskId} \u2014 ${t.title}`;
57652
+ } catch {
57653
+ }
57654
+ const reviewAgainAvailable = !taskInboxItems.some(
57655
+ (item) => item.id === baseBlockItemId && item.status !== "pending" && item.answer === REVIEW_AGAIN_ANSWER
57656
+ );
57657
+ const blockItemId = reviewAgainAvailable ? baseBlockItemId : `${baseBlockItemId}-2`;
57658
+ try {
57659
+ await ctx.client.createInboxItem(input.projectSlug, {
57660
+ id: blockItemId,
57661
+ kind: "question",
57662
+ taskId,
57663
+ body: renderBlockDecisionBody(taskRef, result.unresolved ?? []),
57664
+ origin: "gate_block",
57665
+ questionOptions: buildBlockDecisionOptions(result.escalation?.diagnosis, reviewAgainAvailable)
57666
+ });
57667
+ } catch {
57668
+ }
57669
+ }
57536
57670
  if (operatorClarifications.length > 0) {
57537
57671
  try {
57538
57672
  if (page) {
@@ -57573,7 +57707,7 @@ async function handleConsensusBlocked(result, ctx, input, resolvedTaskId) {
57573
57707
  } else if (operatorClarifications.length > 0) {
57574
57708
  error51 = `Consensus blocked on ${operatorClarifications.length} clarification(s), but the spec page is not linked to a task or plan, so none could be filed as questions.`;
57575
57709
  } else {
57576
- error51 = "Consensus blocked: reviewers did not converge within budget.";
57710
+ error51 = DID_NOT_CONVERGE_ERROR;
57577
57711
  }
57578
57712
  if (result.escalation) {
57579
57713
  error51 += ` (diagnosis: ${result.escalation.diagnosis})`;
@@ -57701,7 +57835,7 @@ function onComplete2(outcome, input) {
57701
57835
  };
57702
57836
  }
57703
57837
  return {
57704
- kind: "info",
57838
+ kind: "completed",
57705
57839
  ...taskId ? { taskId } : {},
57706
57840
  body: taskRef ? `Consensus CONVERGED for ${taskRef} (spec Specs/${input.pageId}); spec_ready set. Ready to create an execution run.` : `Consensus CONVERGED for Specs/${input.pageId}; spec_ready set. Ready to create an execution run.`
57707
57841
  };
@@ -57715,6 +57849,14 @@ function onComplete2(outcome, input) {
57715
57849
  body: taskRef ? `Consensus blocked on ${taskRef} (Specs/${input.pageId}); filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.` : `Consensus blocked on Specs/${input.pageId}; filed ${n} clarifying question${n === 1 ? "" : "s"} \u2014 answer to unblock the run.`
57716
57850
  };
57717
57851
  }
57852
+ if (outcome.kind === "failed" && outcome.error.startsWith(DID_NOT_CONVERGE_ERROR) && taskId) {
57853
+ return {
57854
+ kind: "info",
57855
+ suppressInbox: true,
57856
+ ...taskId ? { taskId } : {},
57857
+ body: taskRef ? `Consensus blocked on ${taskRef} (Specs/${input.pageId}); the reviewers did not agree \u2014 answer the decision question to unblock the run.` : `Consensus blocked on Specs/${input.pageId}; the reviewers did not agree \u2014 answer the decision question to unblock the run.`
57858
+ };
57859
+ }
57718
57860
  const handoffTaskRef = taskRef ? ` (${taskRef})` : "";
57719
57861
  return {
57720
57862
  kind: "handoff",
@@ -57832,7 +57974,7 @@ function onComplete3(outcome, input) {
57832
57974
  };
57833
57975
  }
57834
57976
  return {
57835
- kind: "info",
57977
+ kind: "completed",
57836
57978
  ...taskId ? { taskId } : {},
57837
57979
  body: taskRef ? `Deliverable consensus CONVERGED for ${taskRef} (Deliverables/${input.pageId}); it meets the acceptance criteria.` : `Deliverable consensus CONVERGED for Deliverables/${input.pageId}; it meets the acceptance criteria.`
57838
57980
  };
@@ -57955,6 +58097,7 @@ var mcpToolNames = {
57955
58097
  runSpecConsensus: "run_spec_consensus",
57956
58098
  runCodeReviewerGate: "run_code_reviewer_gate",
57957
58099
  uploadArtifact: "upload_artifact",
58100
+ deliverFile: "deliver_file",
57958
58101
  listArtifactFiles: "list_artifact_files",
57959
58102
  reclaimExecutionRunLease: "reclaim_execution_run_lease",
57960
58103
  restartExecutionRun: "restart_execution_run",
@@ -58540,6 +58683,20 @@ var uploadArtifactInputSchema = projectScopedSchema.merge(
58540
58683
  description: external_exports.string().optional().describe("Shown in the run's artifact history; ignored when runId is not provided.")
58541
58684
  })
58542
58685
  );
58686
+ var deliverFileInputSchema = projectScopedSchema.merge(
58687
+ external_exports.object({
58688
+ filename: external_exports.string().min(1).describe(
58689
+ "Bare filename with extension (e.g. 'notes.md'). No path separators or '..' allowed."
58690
+ ),
58691
+ content: external_exports.string().describe("UTF-8 text content of the file."),
58692
+ class: mrrlinArtifactFileClassSchema.describe(
58693
+ "temp = 48h one-off hand-off; durable = 180d, for spec/evidence references."
58694
+ ),
58695
+ taskId: mrrlinTaskIdSchema.optional(),
58696
+ runId: mrrlinExecutionRunIdSchema.optional(),
58697
+ description: external_exports.string().optional().describe("Shown in the run's artifact history; ignored when runId is not provided.")
58698
+ })
58699
+ );
58543
58700
  var listArtifactFilesInputSchema = projectScopedSchema.merge(
58544
58701
  external_exports.object({
58545
58702
  taskId: mrrlinTaskIdSchema.optional(),
@@ -58625,6 +58782,7 @@ var mcpToolInputSchemas = {
58625
58782
  [mcpToolNames.createInboxItem]: createInboxItemInputSchema,
58626
58783
  [mcpToolNames.decideInboxItem]: decideInboxItemInputSchema,
58627
58784
  [mcpToolNames.uploadArtifact]: uploadArtifactInputSchema,
58785
+ [mcpToolNames.deliverFile]: deliverFileInputSchema,
58628
58786
  [mcpToolNames.listArtifactFiles]: listArtifactFilesInputSchema,
58629
58787
  [mcpToolNames.reclaimExecutionRunLease]: reclaimExecutionRunLeaseInputSchema,
58630
58788
  [mcpToolNames.restartExecutionRun]: restartExecutionRunInputSchema,
@@ -58762,6 +58920,7 @@ var mcpToolDescriptions = {
58762
58920
  [mcpToolNames.createInboxItem]: "Create an operator-inbox item (proposal, check_in, question, handoff, or info) for a project.",
58763
58921
  [mcpToolNames.decideInboxItem]: "Approve, reject, or acknowledge an operator-inbox item. Rejection requires a reason.",
58764
58922
  [mcpToolNames.uploadArtifact]: "Upload a local file (md/txt/html/csv/json/pdf/png/jpg/webp/gif, <=5MB) as a project artifact. Returns a stable markdown link to embed in chat/spec/handoff text. Use class=temp for one-off hand-offs, class=durable for anything referenced by specs or evidence. Pass runId to record the file in that run's artifact history; pass taskId to surface it on the task's Artifacts list.",
58923
+ [mcpToolNames.deliverFile]: "This is the DEFAULT way to deliver a file the agent just composed; it takes the body inline as UTF-8 `content` in one call and needs no local file or writable path; `upload_artifact` is for a file that already exists on disk; use class=temp / class=durable and taskId/runId semantics with a 5MB cap.",
58765
58924
  [mcpToolNames.listArtifactFiles]: "List uploaded artifact files for the project (optionally filtered by taskId/runId), newest first.",
58766
58925
  [mcpToolNames.reclaimExecutionRunLease]: "Clear a stale pending execution-run lease when the lease has aged past staleSeconds. Returns the reclaimed run on success, null when the lease is not stale yet, and surfaces a structured conflict when reclaim retries are exhausted.",
58767
58926
  [mcpToolNames.restartExecutionRun]: "Restart a parked execution run (handoff_requested or handoff_queued) back to pending so the queue can resume it.",
@@ -58908,6 +59067,68 @@ function makeReadTool(client, name, fallbackCode, fallbackMessage, handler, proj
58908
59067
  }
58909
59068
  function createMrrlinTools(options) {
58910
59069
  const client = createMrrlinClient(withAuth(options));
59070
+ async function uploadArtifactBytes(c, args) {
59071
+ const { bytes: bytes3, class: artifactClass, contentType, description, filename, projectSlug, runId, taskId } = args;
59072
+ const created = await c.createArtifactFile(projectSlug, {
59073
+ class: artifactClass,
59074
+ contentType,
59075
+ filename,
59076
+ runId,
59077
+ sizeBytes: bytes3.byteLength,
59078
+ taskId
59079
+ });
59080
+ let stage = "upload";
59081
+ try {
59082
+ if (created.upload.kind === "presigned-post") {
59083
+ const form = new FormData();
59084
+ for (const [field, value] of Object.entries(created.upload.fields)) {
59085
+ form.append(field, value);
59086
+ }
59087
+ form.append("file", new Blob([bytes3], { type: contentType }), filename);
59088
+ const bareFetch = options.fetch ?? globalThis.fetch;
59089
+ const uploadResponse = await bareFetch(created.upload.url, { body: form, method: "POST" });
59090
+ if (!uploadResponse.ok) {
59091
+ throw new Error(
59092
+ `Presigned upload failed: ${uploadResponse.status} ${await uploadResponse.text()}`
59093
+ );
59094
+ }
59095
+ } else {
59096
+ await c.uploadArtifactFileContent(projectSlug, created.artifact.id, bytes3, contentType);
59097
+ }
59098
+ stage = "complete";
59099
+ const completed = await c.completeArtifactFile(projectSlug, created.artifact.id);
59100
+ if (completed.runId) {
59101
+ stage = "append-run-artifact";
59102
+ await c.appendExecutionArtifact(projectSlug, completed.runId, {
59103
+ id: `EA-${completed.id}`,
59104
+ kind: "file",
59105
+ payload: {
59106
+ artifactFileId: completed.id,
59107
+ class: completed.class,
59108
+ contentType: completed.contentType,
59109
+ description: description ?? null,
59110
+ filename: completed.filename,
59111
+ sizeBytes: completed.sizeBytes,
59112
+ stableUrl: created.stableUrl
59113
+ }
59114
+ });
59115
+ }
59116
+ const markdown = contentType.startsWith("image/") ? `![${completed.filename}](${created.stableUrl})` : `[${completed.filename}](${created.stableUrl})`;
59117
+ return {
59118
+ artifactFile: completed,
59119
+ markdown,
59120
+ stableUrl: created.stableUrl
59121
+ };
59122
+ } catch (error51) {
59123
+ const original = error51 instanceof MrrlinApiError ? error51.code : error51 instanceof McpToolCodedError ? error51.code : "ARTIFACT_FILE_UPLOAD_FAILED";
59124
+ const message = error51 instanceof Error ? error51.message : String(error51);
59125
+ const note = stage === "append-run-artifact" ? "file uploaded and completed successfully; only the run-history append failed \u2014 do NOT re-upload, reference the stableUrl directly" : "a pending row may remain; it is reaped automatically after 1h";
59126
+ throw new McpToolCodedError(
59127
+ original,
59128
+ `artifactFileId=${created.artifact.id} stableUrl=${created.stableUrl} (failed at ${stage}; ${note}): ${message}`
59129
+ );
59130
+ }
59131
+ }
58911
59132
  return {
58912
59133
  [mcpToolNames.archivePlan]: makeTool(
58913
59134
  client,
@@ -59475,65 +59696,61 @@ function createMrrlinTools(options) {
59475
59696
  );
59476
59697
  }
59477
59698
  const filename = import_node_path20.default.basename(filePath);
59478
- const created = await c.createArtifactFile(projectSlug, {
59699
+ return uploadArtifactBytes(c, {
59700
+ bytes: bytes3,
59479
59701
  class: artifactClass,
59480
59702
  contentType,
59703
+ description,
59481
59704
  filename,
59705
+ projectSlug,
59482
59706
  runId,
59483
- sizeBytes: bytes3.byteLength,
59484
59707
  taskId
59485
59708
  });
59486
- let stage = "upload";
59487
- try {
59488
- if (created.upload.kind === "presigned-post") {
59489
- const form = new FormData();
59490
- for (const [field, value] of Object.entries(created.upload.fields)) {
59491
- form.append(field, value);
59492
- }
59493
- form.append("file", new Blob([bytes3], { type: contentType }), filename);
59494
- const bareFetch = options.fetch ?? globalThis.fetch;
59495
- const uploadResponse = await bareFetch(created.upload.url, { body: form, method: "POST" });
59496
- if (!uploadResponse.ok) {
59497
- throw new Error(
59498
- `Presigned upload failed: ${uploadResponse.status} ${await uploadResponse.text()}`
59499
- );
59500
- }
59501
- } else {
59502
- await c.uploadArtifactFileContent(projectSlug, created.artifact.id, bytes3, contentType);
59503
- }
59504
- stage = "complete";
59505
- const completed = await c.completeArtifactFile(projectSlug, created.artifact.id);
59506
- if (completed.runId) {
59507
- stage = "append-run-artifact";
59508
- await c.appendExecutionArtifact(projectSlug, completed.runId, {
59509
- id: `EA-${completed.id}`,
59510
- kind: "file",
59511
- payload: {
59512
- artifactFileId: completed.id,
59513
- class: completed.class,
59514
- contentType: completed.contentType,
59515
- description: description ?? null,
59516
- filename: completed.filename,
59517
- sizeBytes: completed.sizeBytes,
59518
- stableUrl: created.stableUrl
59519
- }
59520
- });
59521
- }
59522
- const markdown = contentType.startsWith("image/") ? `![${completed.filename}](${created.stableUrl})` : `[${completed.filename}](${created.stableUrl})`;
59523
- return {
59524
- artifactFile: completed,
59525
- markdown,
59526
- stableUrl: created.stableUrl
59527
- };
59528
- } catch (error51) {
59529
- const original = error51 instanceof MrrlinApiError ? error51.code : error51 instanceof McpToolCodedError ? error51.code : "ARTIFACT_FILE_UPLOAD_FAILED";
59530
- const message = error51 instanceof Error ? error51.message : String(error51);
59531
- const note = stage === "append-run-artifact" ? "file uploaded and completed successfully; only the run-history append failed \u2014 do NOT re-upload, reference the stableUrl directly" : "a pending row may remain; it is reaped automatically after 1h";
59709
+ }
59710
+ ),
59711
+ [mcpToolNames.deliverFile]: makeTool(
59712
+ client,
59713
+ mcpToolNames.deliverFile,
59714
+ "ARTIFACT_FILE_UPLOAD_FAILED",
59715
+ "Unable to deliver artifact file.",
59716
+ async (c, { projectSlug, filename, content, class: artifactClass, taskId, runId, description }) => {
59717
+ if (filename.includes("/") || filename.includes("\\") || filename.includes("..")) {
59718
+ throw new McpToolCodedError(
59719
+ "ARTIFACT_FILE_NAME_INVALID",
59720
+ `Invalid filename '${filename}'; provide a bare filename with no path separators or '..'.`
59721
+ );
59722
+ }
59723
+ const extension2 = import_node_path20.default.extname(filename).toLowerCase();
59724
+ const contentType = ARTIFACT_EXTENSION_TYPES[extension2];
59725
+ if (!contentType) {
59532
59726
  throw new McpToolCodedError(
59533
- original,
59534
- `artifactFileId=${created.artifact.id} stableUrl=${created.stableUrl} (failed at ${stage}; ${note}): ${message}`
59727
+ "ARTIFACT_FILE_TYPE_UNSUPPORTED",
59728
+ `Unsupported artifact extension '${extension2}'. Allowed: ${Object.keys(ARTIFACT_EXTENSION_TYPES).join(", ")}.`
59535
59729
  );
59536
59730
  }
59731
+ const bytes3 = new Uint8Array(Buffer.from(content, "utf8"));
59732
+ if (bytes3.byteLength === 0) {
59733
+ throw new McpToolCodedError(
59734
+ "ARTIFACT_FILE_EMPTY",
59735
+ "File is empty (0 bytes); write content before uploading."
59736
+ );
59737
+ }
59738
+ if (bytes3.byteLength > MRRLIN_ARTIFACT_FILE_MAX_BYTES) {
59739
+ throw new McpToolCodedError(
59740
+ "ARTIFACT_FILE_TOO_LARGE",
59741
+ "Artifact exceeds 5MB; split or summarize instead."
59742
+ );
59743
+ }
59744
+ return uploadArtifactBytes(c, {
59745
+ bytes: bytes3,
59746
+ class: artifactClass,
59747
+ contentType,
59748
+ description,
59749
+ filename,
59750
+ projectSlug,
59751
+ runId,
59752
+ taskId
59753
+ });
59537
59754
  }
59538
59755
  ),
59539
59756
  [mcpToolNames.listArtifactFiles]: makeTool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mrrlin-mcp": "dist/bin.cjs"
@@ -23,10 +23,10 @@
23
23
  "tsx": "^4.22.3",
24
24
  "@mrrlin/client": "0.0.0",
25
25
  "@mrrlin/codex-client": "0.0.0",
26
- "@mrrlin/director-e2e": "0.0.0",
27
- "@mrrlin/schemas": "0.0.0",
28
26
  "@mrrlin/tsconfig": "0.0.0",
29
- "@mrrlin/wiki": "0.0.0"
27
+ "@mrrlin/wiki": "0.0.0",
28
+ "@mrrlin/schemas": "0.0.0",
29
+ "@mrrlin/director-e2e": "0.0.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "@iarna/toml": "^2.2.5",
@@ -47,7 +47,7 @@
47
47
  "dev": "tsx watch src/bin.ts serve",
48
48
  "lint": "eslint .",
49
49
  "start": "node dist/bin.cjs serve",
50
- "test": "node scripts/write-version.mjs && node --import tsx --test $(find src -name '*.test.ts' | sort | tr '\\n' ' ')",
50
+ "test": "pnpm --filter @mrrlin/codex-client build && node scripts/write-version.mjs && node --import tsx --test $(find src -name '*.test.ts' | sort | tr '\\n' ' ')",
51
51
  "typecheck": "node scripts/write-version.mjs && pnpm --filter @mrrlin/schemas build && pnpm --filter @mrrlin/client build && pnpm --filter @mrrlin/wiki build && pnpm --filter @mrrlin/codex-client build && pnpm --filter @mrrlin/director-e2e build && tsc -p tsconfig.json --noEmit"
52
52
  }
53
53
  }