@mrrlin-dev/mcp 0.3.14 → 0.3.15

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 +327 -36
  2. package/package.json +2 -2
package/dist/bin.cjs CHANGED
@@ -20799,9 +20799,8 @@ function renderBlock(block) {
20799
20799
  lines2.push("");
20800
20800
  return lines2.join("\n");
20801
20801
  }
20802
- function tablesEqual(actual, expected) {
20803
- if (typeof actual !== "object" || actual === null) return false;
20804
- const candidate = actual;
20802
+ var KNOWN_MRRLIN_ENV_KEYS = /* @__PURE__ */ new Set(["MRRLIN_STAGING", "MRRLIN_API_BASE_URL", "MRRLIN_API_ALLOW_REMOTE"]);
20803
+ function commandArgsTimeoutEqual(candidate, expected) {
20805
20804
  if (candidate.command !== expected.command) return false;
20806
20805
  if (!Array.isArray(candidate.args)) return false;
20807
20806
  if (candidate.args.length !== expected.args.length) return false;
@@ -20811,6 +20810,12 @@ function tablesEqual(actual, expected) {
20811
20810
  if (candidate.startup_timeout_sec !== void 0 && candidate.startup_timeout_sec !== expected.startup_timeout_sec) {
20812
20811
  return false;
20813
20812
  }
20813
+ return true;
20814
+ }
20815
+ function tablesEqual(actual, expected) {
20816
+ if (typeof actual !== "object" || actual === null) return false;
20817
+ const candidate = actual;
20818
+ if (!commandArgsTimeoutEqual(candidate, expected)) return false;
20814
20819
  const expectedEnv = expected.env ?? {};
20815
20820
  const actualEnv = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
20816
20821
  const expectedKeys = Object.keys(expectedEnv);
@@ -20820,6 +20825,34 @@ function tablesEqual(actual, expected) {
20820
20825
  }
20821
20826
  return true;
20822
20827
  }
20828
+ function isCoherentMrrlinEnv(env) {
20829
+ const keys = Object.keys(env);
20830
+ const staging = env.MRRLIN_STAGING;
20831
+ const url2 = env.MRRLIN_API_BASE_URL;
20832
+ const allowRemote = env.MRRLIN_API_ALLOW_REMOTE;
20833
+ const hasUrl = typeof url2 === "string" && url2.trim() !== "";
20834
+ if (keys.length === 1 && staging === "1") return true;
20835
+ if (keys.length === 1 && hasUrl) return true;
20836
+ if (keys.length === 2 && hasUrl && allowRemote === "1") return true;
20837
+ return false;
20838
+ }
20839
+ function classifyExistingBlock(actual, expected) {
20840
+ if (tablesEqual(actual, expected)) return "equal";
20841
+ if (typeof actual !== "object" || actual === null) return "conflict";
20842
+ const candidate = actual;
20843
+ if (expected.env && Object.keys(expected.env).length > 0) return "conflict";
20844
+ if (!commandArgsTimeoutEqual(candidate, expected)) return "conflict";
20845
+ const env = candidate.env && typeof candidate.env === "object" ? candidate.env : {};
20846
+ const keys = Object.keys(env);
20847
+ if (keys.length === 0) return "conflict";
20848
+ if (!keys.every((key) => KNOWN_MRRLIN_ENV_KEYS.has(key))) return "conflict";
20849
+ if (!isCoherentMrrlinEnv(env)) return "conflict";
20850
+ return "preserve-env-noop";
20851
+ }
20852
+ function renderEnvPreservedNotice(blockId, envKeys) {
20853
+ const keys = [...envKeys].sort().join(", ");
20854
+ return `[mrrlin-mcp install-codex] kept your existing [mcp_servers.${blockId}] env keys (${keys}) so your API target is preserved. Re-run with --force to replace or remove it.`;
20855
+ }
20823
20856
  async function pathExists(target) {
20824
20857
  try {
20825
20858
  await import_promises.default.access(target);
@@ -20845,7 +20878,7 @@ async function installCodex(options) {
20845
20878
  if (!await pathExists(configPath)) {
20846
20879
  const content2 = blocks.map(renderBlock).join("").replace(/^\n/, "");
20847
20880
  await import_promises.default.writeFile(configPath, content2, { mode: 384 });
20848
- return { action: "created", configPath, prompts: await writePrompts() };
20881
+ return { action: "created", configPath, prompts: await writePrompts(), preservedEnvBlocks: [] };
20849
20882
  }
20850
20883
  const realPath = await resolveRealConfigPath(configPath);
20851
20884
  let content = await import_promises.default.readFile(realPath, "utf8");
@@ -20862,10 +20895,19 @@ async function installCodex(options) {
20862
20895
  throw Object.assign(new Error(err.detail), err);
20863
20896
  }
20864
20897
  let action = "noop";
20898
+ let mutated = false;
20899
+ const preservedEnvBlocks = [];
20865
20900
  for (const block of blocks) {
20866
20901
  const existingTable = parsed?.mcp_servers?.[block.id];
20867
20902
  if (existingTable !== void 0) {
20868
- if (tablesEqual(existingTable, block.expected)) continue;
20903
+ const classification = classifyExistingBlock(existingTable, block.expected);
20904
+ if (classification === "equal") continue;
20905
+ if (classification === "preserve-env-noop") {
20906
+ const env = existingTable.env ?? {};
20907
+ preservedEnvBlocks.push({ blockId: block.id, envKeys: Object.keys(env).sort() });
20908
+ if (action === "noop") action = "env-preserved";
20909
+ continue;
20910
+ }
20869
20911
  if (!options.force) {
20870
20912
  const err = {
20871
20913
  code: "CODEX_CONFIG_CONFLICT",
@@ -20876,15 +20918,17 @@ async function installCodex(options) {
20876
20918
  }
20877
20919
  content = replaceBlock(content, block.blockRe, renderBlock(block));
20878
20920
  action = "replaced";
20921
+ mutated = true;
20879
20922
  } else {
20880
20923
  content = appendBlock(content, renderBlock(block));
20881
20924
  if (action !== "replaced") action = "appended";
20925
+ mutated = true;
20882
20926
  }
20883
20927
  }
20884
- if (action !== "noop") {
20928
+ if (mutated) {
20885
20929
  await import_promises.default.writeFile(realPath, content, { mode: 384 });
20886
20930
  }
20887
- return { action, configPath: realPath, prompts: await writePrompts() };
20931
+ return { action, configPath: realPath, prompts: await writePrompts(), preservedEnvBlocks };
20888
20932
  }
20889
20933
  async function installPrompts(codexHome, prompts, forcePrompts) {
20890
20934
  if (prompts.length === 0) return [];
@@ -38564,7 +38608,7 @@ Do NOT derail the current task - if the operator has an in-flight question, answ
38564
38608
  }
38565
38609
 
38566
38610
  // src/_generated/version.ts
38567
- var PKG_VERSION = "0.3.14";
38611
+ var PKG_VERSION = "0.3.15";
38568
38612
 
38569
38613
  // src/api-base-url.ts
38570
38614
  var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
@@ -38778,10 +38822,42 @@ function withTimeout(promise2, ms, message) {
38778
38822
  });
38779
38823
  }
38780
38824
  async function awaitTurnWithDeadline(opts) {
38825
+ const hasIdle = opts.idleTimeoutMs != null && opts.idleTimeoutMessage != null;
38826
+ const overallDeadlineAt = Date.now() + opts.timeoutMs;
38827
+ let overallTimer = null;
38828
+ let idleTimer = null;
38829
+ let settled = false;
38830
+ const clearTimers = () => {
38831
+ settled = true;
38832
+ if (overallTimer) clearTimeout(overallTimer);
38833
+ if (idleTimer) clearTimeout(idleTimer);
38834
+ overallTimer = null;
38835
+ idleTimer = null;
38836
+ };
38837
+ const activity = { touch: () => {
38838
+ } };
38839
+ const idleTimeoutMs = opts.idleTimeoutMs;
38840
+ const idleTimeoutMessage = opts.idleTimeoutMessage;
38841
+ const deadline = new Promise((_, reject) => {
38842
+ overallTimer = setTimeout(() => reject(new Error(opts.timeoutMessage)), opts.timeoutMs);
38843
+ if (idleTimeoutMs != null && idleTimeoutMessage != null) {
38844
+ const armIdle = () => {
38845
+ if (settled) return;
38846
+ if (idleTimer) clearTimeout(idleTimer);
38847
+ idleTimer = setTimeout(() => {
38848
+ reject(new Error(Date.now() >= overallDeadlineAt ? opts.timeoutMessage : idleTimeoutMessage));
38849
+ }, idleTimeoutMs);
38850
+ };
38851
+ activity.touch = armIdle;
38852
+ armIdle();
38853
+ }
38854
+ });
38781
38855
  try {
38782
- await withTimeout(opts.run(), opts.timeoutMs, opts.timeoutMessage);
38856
+ await Promise.race([opts.run(activity), deadline]);
38783
38857
  } catch (error51) {
38784
- const isDeadline = error51 instanceof Error && error51.message === opts.timeoutMessage;
38858
+ clearTimers();
38859
+ const message = error51 instanceof Error ? error51.message : String(error51);
38860
+ const isDeadline = message === opts.timeoutMessage || hasIdle && message === opts.idleTimeoutMessage;
38785
38861
  if (isDeadline) {
38786
38862
  try {
38787
38863
  await withTimeout(
@@ -38795,6 +38871,7 @@ async function awaitTurnWithDeadline(opts) {
38795
38871
  }
38796
38872
  throw error51;
38797
38873
  }
38874
+ clearTimers();
38798
38875
  }
38799
38876
 
38800
38877
  // src/worktree.ts
@@ -38987,9 +39064,12 @@ ${lines2.join("\n")}`;
38987
39064
  var DEFAULT_STALE_SECONDS = 300;
38988
39065
  var DEFAULT_TOUCH_INTERVAL_MS = 6e4;
38989
39066
  var DEFAULT_CLAIM_INTERVAL_SECONDS = 30;
38990
- var DEFAULT_EXEC_TURN_TIMEOUT_SECONDS = 900;
39067
+ var DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS = 600;
39068
+ var DEFAULT_EXEC_RUN_MAX_SECONDS = 3600;
38991
39069
  var DEFAULT_CONTEXT_MAX_ARTIFACTS = 50;
38992
39070
  var EXECUTION_TURN_TIMEOUT_PREFIX = "Execution turn did not complete within";
39071
+ var EXECUTION_TURN_STALL_PREFIX = "Execution turn stalled: no Codex activity within";
39072
+ var EXECUTION_TURN_BUDGET_PREFIX = "Execution turn exceeded the run wall-clock budget";
38993
39073
  var EXECUTION_TURN_TIMEOUT_RETRY_CODE = "execution_turn_timeout_retry";
38994
39074
  var EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY = "Execution turn timed out; requeued for one automatic retry.";
38995
39075
  var EXECUTION_TURN_TIMEOUT_RETRY_CURSOR = "execution_turn_timeout_retry:1";
@@ -38999,14 +39079,26 @@ function readPositiveIntEnv(name, fallback) {
38999
39079
  const parsed = Number.parseInt(raw, 10);
39000
39080
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
39001
39081
  }
39002
- function execTurnTimeoutMs() {
39003
- return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_TIMEOUT_SECONDS) * 1e3;
39082
+ function execTurnIdleTimeoutMs() {
39083
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS) * 1e3;
39084
+ }
39085
+ function execRunMaxMs() {
39086
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS", DEFAULT_EXEC_RUN_MAX_SECONDS) * 1e3;
39087
+ }
39088
+ function explicitLegacyTurnTimeoutMs() {
39089
+ const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS ?? "").trim();
39090
+ if (!raw) return null;
39091
+ const parsed = Number.parseInt(raw, 10);
39092
+ return Number.isInteger(parsed) && parsed > 0 ? parsed * 1e3 : null;
39093
+ }
39094
+ function executionTurnStallMessage(idleMs) {
39095
+ return `${EXECUTION_TURN_STALL_PREFIX} ${idleMs}ms.`;
39004
39096
  }
39005
- function executionTurnTimeoutMessage(timeoutMs) {
39006
- return `${EXECUTION_TURN_TIMEOUT_PREFIX} ${timeoutMs}ms.`;
39097
+ function executionTurnBudgetMessage(budgetMs) {
39098
+ return `${EXECUTION_TURN_BUDGET_PREFIX} (${budgetMs}ms).`;
39007
39099
  }
39008
39100
  function isExecutionTurnTimeoutReason(reason) {
39009
- return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX);
39101
+ return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX) || reason.startsWith(EXECUTION_TURN_STALL_PREFIX);
39010
39102
  }
39011
39103
  function contextMaxArtifacts() {
39012
39104
  return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS", DEFAULT_CONTEXT_MAX_ARTIFACTS);
@@ -39159,11 +39251,15 @@ async function runHasLease(deps, projectSlug, runId, leaseId) {
39159
39251
  return false;
39160
39252
  }
39161
39253
  }
39162
- async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText) {
39254
+ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText, overallMs, phase) {
39163
39255
  const mapper = new CodexBridgeEventMapper();
39164
39256
  mapper.reset();
39165
39257
  let turnError = null;
39166
39258
  let interruptFailed = false;
39259
+ let touch = () => {
39260
+ };
39261
+ const turnStartedAt = Date.now();
39262
+ let lastActivityAt = turnStartedAt;
39167
39263
  const persist = async (event) => {
39168
39264
  if (event.type === "error") turnError = event.content;
39169
39265
  const kind = artifactKindForEvent(event);
@@ -39185,6 +39281,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39185
39281
  });
39186
39282
  let acceptedTurnId = null;
39187
39283
  const unsub = codex.onNotification((method, params) => {
39284
+ const notifThreadId = params?.threadId;
39285
+ if (!notifThreadId || notifThreadId === threadId) {
39286
+ lastActivityAt = Date.now();
39287
+ touch();
39288
+ }
39188
39289
  const events = mapper.mapNotification(
39189
39290
  { method, params },
39190
39291
  threadId
@@ -39192,9 +39293,8 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39192
39293
  for (const event of events) pending.push(persist(event));
39193
39294
  if (method === "turn/completed") {
39194
39295
  const p = params;
39195
- const notifThreadId = p?.threadId;
39196
39296
  const notifTurnId = p?.turn?.id;
39197
- const threadMatches = !notifThreadId || notifThreadId === threadId;
39297
+ const threadMatches = !p?.threadId || p.threadId === threadId;
39198
39298
  const turnMatches = !acceptedTurnId || !notifTurnId || notifTurnId === acceptedTurnId;
39199
39299
  if (threadMatches && turnMatches) resolveTurnEnded?.();
39200
39300
  }
@@ -39204,10 +39304,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39204
39304
  });
39205
39305
  }, deps.touchIntervalMs);
39206
39306
  touchTimer.unref?.();
39307
+ const idleMs = execTurnIdleTimeoutMs();
39207
39308
  try {
39208
- const timeoutMs = execTurnTimeoutMs();
39209
39309
  await awaitTurnWithDeadline({
39210
- run: async () => {
39310
+ run: async (activity) => {
39311
+ touch = activity.touch;
39211
39312
  const accepted = await codex.turn.start({
39212
39313
  threadId,
39213
39314
  input: [{ type: "text", text: inputText }]
@@ -39216,8 +39317,10 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39216
39317
  await turnEnded;
39217
39318
  },
39218
39319
  interrupt: () => codex.turn.interrupt({ threadId }),
39219
- timeoutMs,
39220
- timeoutMessage: executionTurnTimeoutMessage(timeoutMs),
39320
+ timeoutMs: overallMs,
39321
+ timeoutMessage: executionTurnBudgetMessage(overallMs),
39322
+ idleTimeoutMs: idleMs,
39323
+ idleTimeoutMessage: executionTurnStallMessage(idleMs),
39221
39324
  onInterruptFailed: () => {
39222
39325
  interruptFailed = true;
39223
39326
  }
@@ -39226,6 +39329,19 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39226
39329
  await Promise.all(pending);
39227
39330
  } catch (error51) {
39228
39331
  turnError = error51 instanceof Error ? error51.message : String(error51);
39332
+ if (turnError.startsWith(EXECUTION_TURN_STALL_PREFIX) || turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX)) {
39333
+ console.warn(
39334
+ JSON.stringify({
39335
+ event: "execution_turn_deadline",
39336
+ projectSlug,
39337
+ runId,
39338
+ phase,
39339
+ kind: turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX) ? "budget" : "stall",
39340
+ elapsedMs: Date.now() - turnStartedAt,
39341
+ sinceLastActivityMs: Date.now() - lastActivityAt
39342
+ })
39343
+ );
39344
+ }
39229
39345
  } finally {
39230
39346
  clearInterval(touchTimer);
39231
39347
  unsub();
@@ -39247,6 +39363,12 @@ async function driveRun(deps, projectSlug, runId) {
39247
39363
  return;
39248
39364
  }
39249
39365
  const leaseId = claimed.leaseId;
39366
+ const attemptDeadlineMs = Date.now() + execRunMaxMs();
39367
+ const remainingTurnBudgetMs = () => {
39368
+ const remaining = attemptDeadlineMs - Date.now();
39369
+ const legacy = explicitLegacyTurnTimeoutMs();
39370
+ return legacy != null ? Math.min(legacy, remaining) : remaining;
39371
+ };
39250
39372
  const task = await deps.client.getTask(projectSlug, claimed.taskId);
39251
39373
  const compactEnabled = (process.env.MRRLIN_MCP_COMPACT_DISABLE ?? "").trim() !== "1";
39252
39374
  const context = await gatherContext(deps.client, projectSlug, task, runId);
@@ -39284,6 +39406,7 @@ async function driveRun(deps, projectSlug, runId) {
39284
39406
  runId,
39285
39407
  defaultBranch: await getDefaultBranch(projectSlug)
39286
39408
  });
39409
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39287
39410
  await deps.client.updateExecutionRun(projectSlug, runId, {
39288
39411
  worktreeId: worktreePath,
39289
39412
  branch: worktreeBranch(runId)
@@ -39334,11 +39457,17 @@ async function driveRun(deps, projectSlug, runId) {
39334
39457
  ephemeral: null
39335
39458
  },
39336
39459
  onThreadIdChanged: async (newId) => {
39460
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39337
39461
  await deps.client.updateTask(projectSlug, task.id, { directorThreadId: newId });
39338
39462
  }
39339
39463
  });
39340
39464
  if (!resumed) {
39341
- const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle);
39465
+ const primingBudgetMs = remainingTurnBudgetMs();
39466
+ if (primingBudgetMs <= 0) {
39467
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39468
+ return;
39469
+ }
39470
+ const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle, primingBudgetMs, "priming");
39342
39471
  if (!primed.ok) {
39343
39472
  await resetWedgedThread(deps, projectSlug, task.id, primed.interruptFailed);
39344
39473
  await failRun(deps, projectSlug, task, runId, leaseId, primed.error);
@@ -39348,7 +39477,12 @@ async function driveRun(deps, projectSlug, runId) {
39348
39477
  const executionPrompt = resumed ? `${bundle}
39349
39478
 
39350
39479
  Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.` : `Continue executing task ${task.id}. Make the next concrete increment of progress and record artifacts/judgements via MCP tools.`;
39351
- const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt);
39480
+ const workBudgetMs = remainingTurnBudgetMs();
39481
+ if (workBudgetMs <= 0) {
39482
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39483
+ return;
39484
+ }
39485
+ const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt, workBudgetMs, "work");
39352
39486
  if (!outcome.ok) {
39353
39487
  await resetWedgedThread(deps, projectSlug, task.id, outcome.interruptFailed);
39354
39488
  await failRun(deps, projectSlug, task, runId, leaseId, outcome.error);
@@ -44145,6 +44279,9 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44145
44279
  "request behind the task (pure system bookkeeping) \u2014 note that omitting it makes the gate fail-closed.",
44146
44280
  "",
44147
44281
  "Spec authoring contract \u2014 when creating or updating a `Specs/{id}` wiki page:",
44282
+ "- BEGIN the page with YAML frontmatter naming the gated task: a first line `---`, then",
44283
+ " `task: {taskId}` (the task this spec gates, e.g. `task: GT-1023`), then `---`. Never change it",
44284
+ " to another task id \u2014 it is the machine-readable identity the consensus gate validates against.",
44148
44285
  "- In `## Specification`, write each requirement as `### Requirement: <name>` followed by one",
44149
44286
  " SHALL sentence stating the externally visible behavior.",
44150
44287
  "- Give every requirement at least one `#### Scenario: <name>` with `- WHEN ...` / `- THEN ...`",
@@ -49624,6 +49761,71 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
49624
49761
  return { outcome: "CONSENSUS_BLOCKED", rounds: input.rounds, finalArtifact: artifact, history };
49625
49762
  }
49626
49763
 
49764
+ // src/consensus/spec-identity.ts
49765
+ function validateSpecIdentity(markdown, taskId) {
49766
+ const target = taskId.toUpperCase();
49767
+ const fieldToken = frontmatterTaskToken(markdown);
49768
+ if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
49769
+ const headingToken = firstHeadingToken(markdown);
49770
+ if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
49771
+ return true;
49772
+ }
49773
+ var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
49774
+ var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
49775
+ var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
49776
+ function frontmatterTaskToken(markdown) {
49777
+ const lines2 = markdown.split("\n");
49778
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
49779
+ if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
49780
+ for (let i = firstContent + 1; i < lines2.length; i++) {
49781
+ const trimmed = (lines2[i] ?? "").trim();
49782
+ if (trimmed === "---") break;
49783
+ const m = FRONTMATTER_TASK.exec(trimmed);
49784
+ if (m) return firstTaskIdToken(m[1] ?? "");
49785
+ }
49786
+ return null;
49787
+ }
49788
+ function firstHeadingToken(markdown) {
49789
+ const heading = firstHeading(markdown);
49790
+ return heading === null ? null : firstTaskIdToken(heading);
49791
+ }
49792
+ function firstHeading(markdown) {
49793
+ const lines2 = markdown.split("\n");
49794
+ let i = 0;
49795
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
49796
+ if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
49797
+ i = firstContent + 1;
49798
+ while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
49799
+ i++;
49800
+ }
49801
+ let inFence = false;
49802
+ let inComment = false;
49803
+ for (; i < lines2.length; i++) {
49804
+ const line = lines2[i] ?? "";
49805
+ const trimmed = line.trim();
49806
+ if (inComment) {
49807
+ if (trimmed.includes("-->")) inComment = false;
49808
+ continue;
49809
+ }
49810
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
49811
+ inFence = !inFence;
49812
+ continue;
49813
+ }
49814
+ if (inFence) continue;
49815
+ if (trimmed.startsWith("<!--")) {
49816
+ if (!trimmed.includes("-->")) inComment = true;
49817
+ continue;
49818
+ }
49819
+ const m = ATX_HEADING.exec(line);
49820
+ if (m) return m[1] ?? "";
49821
+ }
49822
+ return null;
49823
+ }
49824
+ function firstTaskIdToken(text) {
49825
+ const m = TASK_ID_TOKEN.exec(text);
49826
+ return m ? m[0] : null;
49827
+ }
49828
+
49627
49829
  // src/consensus/spec-gate.ts
49628
49830
  var SPEC_HEADS = ["plan-reviewer", "architect", "security-analyst", "scope-analyst"];
49629
49831
  async function runSpecGate(_target, deps, opts) {
@@ -49645,6 +49847,7 @@ async function runSpecGate(_target, deps, opts) {
49645
49847
  const heads = [...SPEC_HEADS, ...extraHeads.filter((h) => !SPEC_HEADS.includes(h))];
49646
49848
  const spec = await deps.readSpec();
49647
49849
  let currentHash = spec.contentHash;
49850
+ let currentMarkdown = spec.markdownBody;
49648
49851
  let conflicted = false;
49649
49852
  const result = await runConsensus({
49650
49853
  artifact: spec.markdownBody,
@@ -49656,12 +49859,20 @@ async function runSpecGate(_target, deps, opts) {
49656
49859
  revise: async (artifact, accepted) => {
49657
49860
  const revised = await deps.runReviser(artifact, accepted);
49658
49861
  if (!revised.ok) return artifact;
49862
+ if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
49863
+ process.stderr.write(
49864
+ `[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
49865
+ `
49866
+ );
49867
+ return artifact;
49868
+ }
49659
49869
  const cas = await deps.casUpdateSpec(revised.text, currentHash);
49660
49870
  if (!cas.ok) {
49661
49871
  conflicted = true;
49662
49872
  throw new Error("SPEC_CHANGED_DURING_REVIEW");
49663
49873
  }
49664
49874
  currentHash = cas.contentHash;
49875
+ currentMarkdown = revised.text;
49665
49876
  return revised.text;
49666
49877
  }
49667
49878
  // Only OUR conflict throw is swallowed (→ null). Anything else is a real
@@ -49671,6 +49882,22 @@ async function runSpecGate(_target, deps, opts) {
49671
49882
  await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
49672
49883
  return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
49673
49884
  }
49885
+ if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
49886
+ process.stderr.write(
49887
+ `[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
49888
+ `
49889
+ );
49890
+ return {
49891
+ outcome: "CONSENSUS_BLOCKED",
49892
+ clarifications: [
49893
+ {
49894
+ question: `SPEC_IDENTITY_MISMATCH: this spec page gates ${intent.taskId}, but its heading identifies a different task \u2014 rewrite the spec heading and content for ${intent.taskId}, or re-link the task.`,
49895
+ whyBlocking: "converged spec content belongs to a different task than the one it gates",
49896
+ category: "scope"
49897
+ }
49898
+ ]
49899
+ };
49900
+ }
49674
49901
  await deps.writeEvidence({
49675
49902
  specPageId: spec.pageId,
49676
49903
  outcome: result.outcome,
@@ -49888,6 +50115,21 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
49888
50115
  var import_node_path13 = __toESM(require("node:path"), 1);
49889
50116
  var import_node_url2 = require("node:url");
49890
50117
 
50118
+ // src/consensus/resolve-gated-task.ts
50119
+ async function resolveGatedTaskId(client, projectSlug, page) {
50120
+ const tasks = await client.listTasks(projectSlug);
50121
+ const linking = tasks.find((t) => t.specWikiPageId === page.id);
50122
+ if (linking) {
50123
+ if (page.taskId !== linking.id) {
50124
+ throw new Error(
50125
+ `spec page ${page.id} is linked by task ${linking.id} (tasks.spec_wiki_page_id) but its wiki_pages.task_id back-pointer is ${page.taskId === null ? "null" : page.taskId} \u2014 refusing to gate with a drifted identity`
50126
+ );
50127
+ }
50128
+ return linking.id;
50129
+ }
50130
+ return page.taskId;
50131
+ }
50132
+
49891
50133
  // src/consensus/code-gate-git.ts
49892
50134
  var import_node_child_process6 = require("node:child_process");
49893
50135
  var DIFF_MAX_BYTES = 2e5;
@@ -50132,6 +50374,23 @@ function loadPersona(name) {
50132
50374
  personaCache.set(name, content);
50133
50375
  return content;
50134
50376
  }
50377
+ function specHeadIntentBlock(taskId, title, seed) {
50378
+ const identity = taskId ? `--- TASK IDENTITY (the spec MUST be about THIS task; if its scope or direction contradicts this identity, raise a \`scope\` issue instead of steering the spec elsewhere) ---
50379
+ TASK ${taskId}: ${title ?? ""}
50380
+
50381
+ ` : "";
50382
+ const intent = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
50383
+ ${seed}
50384
+
50385
+ ` : "";
50386
+ return `${identity}${intent}`;
50387
+ }
50388
+ function reviserIdentityInstructions(taskId, title) {
50389
+ const lock = taskId ? `This spec gates task ${taskId}${title ? ` ("${title}")` : ""}. You MUST NOT change the spec's task identity or heading, retarget it to a different task id, or invert its direction (e.g. live-submission vs preparation-only). Preserve the leading \`task: ${taskId}\` frontmatter EXACTLY; never change it to another task id. Address ONLY the accepted issues.
50390
+
50391
+ ` : "";
50392
+ return `${lock}You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.`;
50393
+ }
50135
50394
  function buildSpecGateDeps(args) {
50136
50395
  const { client, projectSlug, specPageId, cwd, codexExecutable, extraHeadCount = 0 } = args;
50137
50396
  const rounds = Number(process.env["MRRLIN_CONSENSUS_ROUNDS"] ?? 3);
@@ -50142,10 +50401,16 @@ function buildSpecGateDeps(args) {
50142
50401
  if (!intentPromise) {
50143
50402
  intentPromise = (async () => {
50144
50403
  const page = await client.getWikiPage(projectSlug, specPageId);
50145
- const taskId = page?.taskId ?? null;
50146
- if (taskId === null) return { taskId: null, seed: null };
50404
+ if (!page) {
50405
+ throw new Error(`spec page ${projectSlug}/${specPageId} not found \u2014 cannot gate`);
50406
+ }
50407
+ const taskId = await resolveGatedTaskId(client, projectSlug, { id: page.id, taskId: page.taskId ?? null });
50408
+ if (taskId === null) return { taskId: null, seed: null, title: null };
50147
50409
  const task = await client.getTask(projectSlug, taskId);
50148
- return { taskId, seed: task?.operatorSeedRequest ?? null };
50410
+ if (!task) {
50411
+ throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
50412
+ }
50413
+ return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "" };
50149
50414
  })();
50150
50415
  }
50151
50416
  return intentPromise;
@@ -50170,11 +50435,8 @@ function buildSpecGateDeps(args) {
50170
50435
  },
50171
50436
  async runHead(personaName, artifact, dismissed) {
50172
50437
  const persona = loadPersona(personaName);
50173
- const { seed } = await loadIntent();
50174
- const intentBlock = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
50175
- ${seed}
50176
-
50177
- ` : "";
50438
+ const { taskId, title, seed } = await loadIntent();
50439
+ const intentBlock = specHeadIntentBlock(taskId, title, seed);
50178
50440
  return runCodexExec(
50179
50441
  {
50180
50442
  prompt: `Review this spec against the operator's intent.
@@ -50205,6 +50467,7 @@ ${artifact}`,
50205
50467
  },
50206
50468
  async runReviser(artifact, accepted) {
50207
50469
  const acceptedList = accepted.map((a) => `- (${a.category}) ${a.text}`).join("\n");
50470
+ const { taskId, title } = await loadIntent();
50208
50471
  return runCodexExec(
50209
50472
  {
50210
50473
  prompt: `Accepted issues:
@@ -50212,7 +50475,7 @@ ${acceptedList}
50212
50475
 
50213
50476
  --- SPEC ---
50214
50477
  ${artifact}`,
50215
- developerInstructions: "You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.",
50478
+ developerInstructions: reviserIdentityInstructions(taskId, title),
50216
50479
  cwd,
50217
50480
  timeoutMs,
50218
50481
  sandbox: "read-only",
@@ -50363,6 +50626,13 @@ async function cachedResult(client, input) {
50363
50626
  if (!page) return null;
50364
50627
  const review = await client.getSpecConsensusReview(input.projectSlug, input.pageId, page.contentHash);
50365
50628
  if (review?.outcome === "CONVERGED") {
50629
+ const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
50630
+ id: page.id,
50631
+ taskId: page.taskId ?? null
50632
+ });
50633
+ if (gatedTaskId !== null && !validateSpecIdentity(page.markdownBody, gatedTaskId)) {
50634
+ return null;
50635
+ }
50366
50636
  return { status: "already_converged", payload: { outcome: "CONVERGED", contentHash: page.contentHash } };
50367
50637
  }
50368
50638
  if (review?.outcome === "CONSENSUS_BLOCKED") {
@@ -52585,6 +52855,13 @@ Usage:
52585
52855
  non-loopback URL (see \`mrrlin-mcp serve\` above).
52586
52856
  URL and MRRLIN_STAGING are mutually exclusive at
52587
52857
  bake time \u2014 URL wins, mirrors serve's resolver.
52858
+ Re-running BARE (no env vars) over a block you
52859
+ already pinned to staging/a remote target is safe:
52860
+ it keeps your env verbatim (reported as
52861
+ 'env-preserved') instead of conflicting, so the
52862
+ same setup command doubles as an update command.
52863
+ To CHANGE or REMOVE the baked target, re-run with
52864
+ --force (or the new env vars).
52588
52865
  --force replaces an existing conflicting block.
52589
52866
  --force-prompts overwrites prompt files that
52590
52867
  already exist with different content. WITHOUT it,
@@ -52631,8 +52908,18 @@ Usage:
52631
52908
  - MRRLIN_DIRECTOR_BRIDGE_CODEX_IDLE_SECONDS (default 300;
52632
52909
  how long the warm codex app-server is kept alive
52633
52910
  between turns before it is released)
52634
- - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (default 900;
52635
- whole-turn deadline for one autonomous execution turn)
52911
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS (default 600;
52912
+ an execution turn is cut only after this long with NO codex
52913
+ signal at all \u2014 long-but-active turns keep running. A stall
52914
+ earns one automatic retry)
52915
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS (default 3600;
52916
+ overall wall-clock budget for the active-turn portion of one
52917
+ claim attempt, measured from claim. Exceeding it hands off with
52918
+ NO retry. Setup/post-turn hangs are bounded by the stale reaper,
52919
+ not by this budget)
52920
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (DEPRECATED;
52921
+ unset by default. When set, an extra per-turn hard cap: the
52922
+ effective turn deadline becomes min(this, remaining run budget))
52636
52923
  - MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS (default 50;
52637
52924
  most-recent artifacts kept in the execution context bundle)
52638
52925
 
@@ -52747,6 +53034,10 @@ async function main() {
52747
53034
  });
52748
53035
  process.stderr.write(`[mrrlin-mcp install-codex] ${result.action} ${result.configPath}
52749
53036
  `);
53037
+ for (const preserved of result.preservedEnvBlocks) {
53038
+ process.stderr.write(`${renderEnvPreservedNotice(preserved.blockId, preserved.envKeys)}
53039
+ `);
53040
+ }
52750
53041
  for (const p of result.prompts) {
52751
53042
  process.stderr.write(`[mrrlin-mcp install-codex] prompt ${p.name}: ${p.action} ${p.promptPath}
52752
53043
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/mcp",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mrrlin-mcp": "dist/bin.cjs"
@@ -25,8 +25,8 @@
25
25
  "@mrrlin/client": "0.0.0",
26
26
  "@mrrlin/codex-client": "0.0.0",
27
27
  "@mrrlin/director-e2e": "0.0.0",
28
- "@mrrlin/schemas": "0.0.0",
29
28
  "@mrrlin/tsconfig": "0.0.0",
29
+ "@mrrlin/schemas": "0.0.0",
30
30
  "@mrrlin/wiki": "0.0.0"
31
31
  },
32
32
  "dependencies": {