@mrrlin-dev/mcp 0.3.14 → 0.3.20

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/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 [];
@@ -20941,10 +20985,15 @@ var import_node_child_process4 = require("node:child_process");
20941
20985
  var import_node_crypto5 = __toESM(require("node:crypto"), 1);
20942
20986
 
20943
20987
  // src/turn-replay-buffer.ts
20944
- var DEFAULT_GRACE_MS = 3e4;
20945
20988
  var DEFAULT_MAX_EVENTS = 2e3;
20946
20989
  var DEFAULT_MAX_BYTES = 4e6;
20947
- var DEFAULT_POST_COMPLETION_TTL_MS = 1e4;
20990
+ var DEFAULT_ACK_LINGER_MS = 1e4;
20991
+ var DEFAULT_RESULT_RETENTION_MS = 36e5;
20992
+ function resultRetentionMs() {
20993
+ const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_RESULT_RETENTION_SECONDS ?? "").trim();
20994
+ const parsed = Number.parseInt(raw, 10);
20995
+ return Number.isInteger(parsed) && parsed > 0 ? parsed * 1e3 : DEFAULT_RESULT_RETENTION_MS;
20996
+ }
20948
20997
  function createReplayBuffer(opts) {
20949
20998
  return {
20950
20999
  turnId: opts.turnId,
@@ -20956,14 +21005,15 @@ function createReplayBuffer(opts) {
20956
21005
  terminalDelivered: false,
20957
21006
  replaying: false,
20958
21007
  outboundQueue: [],
20959
- graceTimer: null,
20960
21008
  finishedAt: null,
21009
+ finishedSuspendedMs: null,
21010
+ ackedAt: null,
20961
21011
  bytesUsed: 0,
20962
- graceMs: opts.graceMs ?? DEFAULT_GRACE_MS,
20963
21012
  maxEvents: opts.maxEvents ?? DEFAULT_MAX_EVENTS,
20964
21013
  maxBytes: opts.maxBytes ?? DEFAULT_MAX_BYTES,
20965
- postCompletionTtlMs: opts.postCompletionTtlMs ?? DEFAULT_POST_COMPLETION_TTL_MS,
20966
- triggerGrace: null
21014
+ ackLingerMs: opts.ackLingerMs ?? DEFAULT_ACK_LINGER_MS,
21015
+ retentionMs: opts.retentionMs ?? resultRetentionMs(),
21016
+ triggerDetach: null
20967
21017
  };
20968
21018
  }
20969
21019
  function recordOutgoing(buf, msg) {
@@ -20972,9 +21022,10 @@ function recordOutgoing(buf, msg) {
20972
21022
  buf.events.push(entry);
20973
21023
  buf.bytesUsed += approxByteSize(msg);
20974
21024
  while ((buf.events.length > buf.maxEvents || buf.bytesUsed > buf.maxBytes) && buf.events.length > 0) {
20975
- const dropped = buf.events.shift();
20976
- if (!dropped) break;
20977
- buf.bytesUsed -= approxByteSize(dropped.msg);
21025
+ const head = buf.events[0];
21026
+ if (!head || isTerminalEvent(head.msg)) break;
21027
+ buf.events.shift();
21028
+ buf.bytesUsed -= approxByteSize(head.msg);
20978
21029
  }
20979
21030
  return entry;
20980
21031
  }
@@ -20983,19 +21034,7 @@ function eventsAfter(buf, lastSeq) {
20983
21034
  if (first && lastSeq < first.seq - 1) return null;
20984
21035
  return buf.events.filter((e) => e.seq > lastSeq);
20985
21036
  }
20986
- function enterGrace(buf, onExpire) {
20987
- if (buf.graceTimer !== null) return;
20988
- buf.state = "grace";
20989
- buf.graceTimer = setTimeout(onExpire, buf.graceMs);
20990
- }
20991
- function cancelGrace(buf) {
20992
- if (buf.graceTimer !== null) {
20993
- clearTimeout(buf.graceTimer);
20994
- buf.graceTimer = null;
20995
- }
20996
- if (buf.state === "grace") buf.state = "live";
20997
- }
20998
- function recordInterrupted(buf) {
21037
+ function recordInterrupted(buf, now = Date.now(), suspendedMsNow = 0) {
20999
21038
  if (buf.terminalDelivered) return null;
21000
21039
  const msg = {
21001
21040
  type: "event",
@@ -21003,16 +21042,31 @@ function recordInterrupted(buf) {
21003
21042
  };
21004
21043
  const entry = recordOutgoing(buf, msg);
21005
21044
  buf.terminalDelivered = true;
21045
+ if (buf.finishedAt === null) {
21046
+ buf.finishedAt = now;
21047
+ buf.finishedSuspendedMs = suspendedMsNow;
21048
+ }
21006
21049
  return entry;
21007
21050
  }
21008
- function markCompleted(buf, now) {
21051
+ function markCompleted(buf, now, suspendedMsNow = 0) {
21009
21052
  buf.state = "completed";
21010
21053
  buf.finishedAt = now;
21054
+ buf.finishedSuspendedMs = suspendedMsNow;
21055
+ }
21056
+ function recordAck(buf, turnId, lastSeq, now) {
21057
+ if (buf.turnId !== turnId) return false;
21058
+ if (lastSeq !== buf.lastSeq) return false;
21059
+ if (buf.state !== "completed" && buf.state !== "interrupted") return false;
21060
+ if (buf.ackedAt === null) buf.ackedAt = now;
21061
+ return true;
21011
21062
  }
21012
- function shouldGc(buf, now) {
21013
- if (buf.state !== "completed") return false;
21063
+ function shouldGc(buf, now, suspendedMsNow = 0) {
21064
+ if (buf.state !== "completed" && buf.state !== "interrupted") return false;
21014
21065
  if (buf.finishedAt === null) return false;
21015
- return now - buf.finishedAt > buf.postCompletionTtlMs;
21066
+ if (buf.ackedAt !== null) return now - buf.ackedAt > buf.ackLingerMs;
21067
+ const suspendedSinceFinish = suspendedMsNow - (buf.finishedSuspendedMs ?? suspendedMsNow);
21068
+ const awakeElapsed = now - buf.finishedAt - Math.max(0, suspendedSinceFinish);
21069
+ return awakeElapsed > buf.retentionMs;
21016
21070
  }
21017
21071
  function incrementBindEpoch(buf) {
21018
21072
  return ++buf.bindEpoch;
@@ -21033,6 +21087,41 @@ function approxByteSize(msg) {
21033
21087
  }
21034
21088
  }
21035
21089
 
21090
+ // src/suspension-detector.ts
21091
+ var DEFAULT_SUSPEND_TICK_MS = 15e3;
21092
+ var DEFAULT_SUSPEND_GAP_MS = 45e3;
21093
+ function readPositiveMs(name, fallback) {
21094
+ const raw = (process.env[name] ?? "").trim();
21095
+ const parsed = Number.parseInt(raw, 10);
21096
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
21097
+ }
21098
+ function suspendTickMs() {
21099
+ return readPositiveMs("MRRLIN_DIRECTOR_BRIDGE_SUSPEND_TICK_MS", DEFAULT_SUSPEND_TICK_MS);
21100
+ }
21101
+ function suspendGapMs() {
21102
+ return readPositiveMs("MRRLIN_DIRECTOR_BRIDGE_SUSPEND_GAP_MS", DEFAULT_SUSPEND_GAP_MS);
21103
+ }
21104
+ function createSuspensionDetector(opts) {
21105
+ const tickMs = opts?.tickMs ?? suspendTickMs();
21106
+ const gapMs = opts?.gapMs ?? suspendGapMs();
21107
+ const now = opts?.now ?? Date.now;
21108
+ const setI = opts?.setIntervalFn ?? setInterval;
21109
+ const clearI = opts?.clearIntervalFn ?? clearInterval;
21110
+ let last = now();
21111
+ let suspended = 0;
21112
+ const timer = setI(() => {
21113
+ const t = now();
21114
+ const gap = t - last;
21115
+ last = t;
21116
+ if (gap >= gapMs) suspended += gap - tickMs;
21117
+ }, tickMs);
21118
+ timer.unref?.();
21119
+ return {
21120
+ suspendedMs: () => suspended,
21121
+ dispose: () => clearI(timer)
21122
+ };
21123
+ }
21124
+
21036
21125
  // src/replay-state-machine.ts
21037
21126
  function transition(state, event) {
21038
21127
  if (event.kind === "resume" && !event.epochMatch) return { next: "invalid" };
@@ -21040,7 +21129,12 @@ function transition(state, event) {
21040
21129
  case "live":
21041
21130
  switch (event.kind) {
21042
21131
  case "socket_close":
21043
- return { next: "grace", sideEffects: ["start_grace_timer"] };
21132
+ return { next: "detached", sideEffects: [] };
21133
+ case "resume":
21134
+ return {
21135
+ next: "live",
21136
+ sideEffects: ["rebind_sink_increment_epoch", "replay_drain"]
21137
+ };
21044
21138
  case "codex_terminal":
21045
21139
  return { next: "completed", sideEffects: ["mark_finished"] };
21046
21140
  case "stop":
@@ -21051,35 +21145,22 @@ function transition(state, event) {
21051
21145
  default:
21052
21146
  return { next: "invalid" };
21053
21147
  }
21054
- case "grace":
21148
+ case "detached":
21055
21149
  switch (event.kind) {
21150
+ case "socket_close":
21151
+ return { next: "detached", sideEffects: [] };
21152
+ // idempotent
21056
21153
  case "resume":
21057
21154
  return {
21058
21155
  next: "live",
21059
- sideEffects: [
21060
- "cancel_grace_timer",
21061
- "rebind_sink_increment_epoch",
21062
- "replay_drain"
21063
- ]
21064
- };
21065
- case "grace_expire":
21066
- return {
21067
- next: "interrupted",
21068
- sideEffects: ["record_interrupted", "fire_turn_interrupt"]
21156
+ sideEffects: ["rebind_sink_increment_epoch", "replay_drain"]
21069
21157
  };
21158
+ case "codex_terminal":
21159
+ return { next: "completed", sideEffects: ["mark_finished"] };
21070
21160
  case "stop":
21071
21161
  return {
21072
21162
  next: "interrupted",
21073
- sideEffects: [
21074
- "cancel_grace_timer",
21075
- "record_interrupted",
21076
- "fire_turn_interrupt"
21077
- ]
21078
- };
21079
- case "codex_terminal":
21080
- return {
21081
- next: "completed",
21082
- sideEffects: ["cancel_grace_timer", "mark_finished"]
21163
+ sideEffects: ["record_interrupted", "fire_turn_interrupt"]
21083
21164
  };
21084
21165
  default:
21085
21166
  return { next: "invalid" };
@@ -36437,10 +36518,12 @@ var mrrlinTaskStatusSchema = external_exports.enum([
36437
36518
  "closed_done",
36438
36519
  "closed_no_result"
36439
36520
  ]);
36521
+ var mrrlinTerminalTaskStatuses = ["closed_done", "closed_no_result"];
36440
36522
  var mrrlinTaskTypeSchema = external_exports.enum(["task", "bug"]);
36441
36523
  var mrrlinTaskImpactSchema = external_exports.enum(["low", "medium", "high"]);
36442
36524
  var mrrlinAutonomyLevelSchema = external_exports.enum(["auto", "minimal_human", "human_only"]);
36443
36525
  var mrrlinAutoDeploySchema = external_exports.enum(["off", "dev", "prod"]);
36526
+ var mrrlinTaskDomainSchema = external_exports.enum(["code", "seo", "marketing", "content"]);
36444
36527
  var mrrlinSpecApprovedSchema = external_exports.enum(["pending", "approved", "skipped", "skipped_legacy"]);
36445
36528
  var mrrlinTaskSourceSchema = external_exports.enum(["fixture-import", "api"]);
36446
36529
  var mrrlinPlanStatusSchema = external_exports.enum(["active", "paused", "done", "archived"]);
@@ -36472,6 +36555,27 @@ function normalizeTaskTags(tags) {
36472
36555
  }
36473
36556
  return [...normalized].sort();
36474
36557
  }
36558
+ var terminalTaskStatusSet = new Set(mrrlinTerminalTaskStatuses);
36559
+ function isTaskTerminalStatus(status) {
36560
+ return terminalTaskStatusSet.has(status);
36561
+ }
36562
+ function getActiveBlockedByIds(blockedBy, blockers) {
36563
+ const blockersById = /* @__PURE__ */ new Map();
36564
+ for (const blocker of blockers) {
36565
+ if (!blocker)
36566
+ continue;
36567
+ blockersById.set(blocker.id, blocker.status);
36568
+ }
36569
+ const active = [];
36570
+ for (const blockerId of blockedBy ?? []) {
36571
+ const status = blockersById.get(blockerId);
36572
+ if (!status)
36573
+ continue;
36574
+ if (!isTaskTerminalStatus(status))
36575
+ active.push(blockerId);
36576
+ }
36577
+ return active;
36578
+ }
36475
36579
  var mrrlinTaskTagSchema = external_exports.string().min(1).max(40).transform(normalizeTaskTagInput).pipe(external_exports.string().min(1).max(40));
36476
36580
  var mrrlinTaskTagsSchema = external_exports.array(mrrlinTaskTagSchema).default([]).transform(normalizeTaskTags);
36477
36581
  var mrrlinExecutionRunStatusSchema = external_exports.enum([
@@ -36525,6 +36629,10 @@ var mrrlinTaskAssigneeSchema = external_exports.union([external_exports.string()
36525
36629
  var mrrlinTaskSchema = external_exports.object({
36526
36630
  assignee: mrrlinTaskAssigneeSchema,
36527
36631
  autoDeploy: mrrlinAutoDeploySchema,
36632
+ // ADR 0020. OUTPUT-optional (not defaulted-required) so hardcoded task fixtures/mocks across the
36633
+ // codebase need no churn; the DB column is NOT NULL default 'code', so real rows always carry it,
36634
+ // and resolveHeads() treats an absent domain as `code`. Persisted create/update use their own forms.
36635
+ domain: mrrlinTaskDomainSchema.optional(),
36528
36636
  autonomyLevel: mrrlinAutonomyLevelSchema,
36529
36637
  autonomyReason: external_exports.string().min(1).max(240),
36530
36638
  blockedBy: external_exports.array(mrrlinTaskIdSchema),
@@ -36642,7 +36750,10 @@ var mrrlinProjectSummarySchema = external_exports.object({
36642
36750
  emoji: mrrlinProjectEmojiSchema,
36643
36751
  github: mrrlinGithubBindingSchema,
36644
36752
  onboardingKickoffState: external_exports.enum(["pending", "sent"]).nullable().optional(),
36645
- slug: mrrlinProjectSlugSchema
36753
+ slug: mrrlinProjectSlugSchema,
36754
+ // ADR 0020: default consensus domain for this project's plan/project-scoped specs. Output-optional
36755
+ // (DB column is NOT NULL default 'code') so existing project fixtures/upsert callers need no churn.
36756
+ defaultDomain: mrrlinTaskDomainSchema.optional()
36646
36757
  });
36647
36758
  var mrrlinProjectUpsertSchema = mrrlinProjectSummarySchema.omit({
36648
36759
  onboardingKickoffState: true,
@@ -36691,6 +36802,9 @@ var mrrlinProjectSnapshotSchema = external_exports.object({
36691
36802
  });
36692
36803
  var mrrlinTaskCreateSchema = external_exports.object({
36693
36804
  autoDeploy: mrrlinAutoDeploySchema.default("off"),
36805
+ // Input-optional so existing createTask call sites need no churn; createTask fills 'code' for the
36806
+ // INSERT (the pg column is NOT NULL, so a concrete value is required, not undefined→NULL).
36807
+ domain: mrrlinTaskDomainSchema.optional(),
36694
36808
  autonomyLevel: mrrlinAutonomyLevelSchema.default("minimal_human"),
36695
36809
  autonomyReason: external_exports.string().min(1).max(240).default("Needs human review before automation."),
36696
36810
  category: taxonomyCategorySchema,
@@ -36714,6 +36828,7 @@ var mrrlinTaskUpdateSchema = external_exports.object({
36714
36828
  actor: external_exports.string().min(1).optional(),
36715
36829
  assignee: mrrlinTaskAssigneeSchema.optional(),
36716
36830
  autoDeploy: mrrlinAutoDeploySchema.optional(),
36831
+ domain: mrrlinTaskDomainSchema.optional(),
36717
36832
  autonomyLevel: mrrlinAutonomyLevelSchema.optional(),
36718
36833
  autonomyReason: external_exports.string().min(1).max(240).optional(),
36719
36834
  category: taxonomyCategorySchema.optional(),
@@ -37347,6 +37462,43 @@ var mrrlinInboxItemActionPayloadSchema = external_exports.object({
37347
37462
  // (worktree + push/PR path). Scheduler proposals set it; legacy payloads omit it.
37348
37463
  codeExecution: external_exports.boolean().optional()
37349
37464
  });
37465
+ var mrrlinInboxQuestionOptionSchema = external_exports.object({
37466
+ answer: external_exports.string().trim().min(1),
37467
+ description: external_exports.string().trim().min(1),
37468
+ label: external_exports.string().trim().min(1),
37469
+ recommended: external_exports.boolean().optional()
37470
+ });
37471
+ var mrrlinInboxQuestionOptionsSchema = external_exports.array(mrrlinInboxQuestionOptionSchema).min(1).max(3).superRefine((options, context) => {
37472
+ const labels = /* @__PURE__ */ new Set();
37473
+ const answers = /* @__PURE__ */ new Set();
37474
+ let recommendedCount = 0;
37475
+ for (const [index, option] of options.entries()) {
37476
+ if (labels.has(option.label)) {
37477
+ context.addIssue({
37478
+ code: external_exports.ZodIssueCode.custom,
37479
+ message: "questionOptions labels must be unique after trimming",
37480
+ path: [index, "label"]
37481
+ });
37482
+ }
37483
+ labels.add(option.label);
37484
+ if (answers.has(option.answer)) {
37485
+ context.addIssue({
37486
+ code: external_exports.ZodIssueCode.custom,
37487
+ message: "questionOptions answers must be unique after trimming",
37488
+ path: [index, "answer"]
37489
+ });
37490
+ }
37491
+ answers.add(option.answer);
37492
+ if (option.recommended === true)
37493
+ recommendedCount += 1;
37494
+ }
37495
+ if (recommendedCount > 1) {
37496
+ context.addIssue({
37497
+ code: external_exports.ZodIssueCode.custom,
37498
+ message: "questionOptions may mark at most one option as recommended"
37499
+ });
37500
+ }
37501
+ });
37350
37502
  var mrrlinInboxItemSchema = external_exports.object({
37351
37503
  actionPayload: mrrlinInboxItemActionPayloadSchema.nullable(),
37352
37504
  answer: external_exports.string().nullable(),
@@ -37362,9 +37514,18 @@ var mrrlinInboxItemSchema = external_exports.object({
37362
37514
  origin: external_exports.string().nullable().optional(),
37363
37515
  planId: mrrlinPlanIdSchema.nullable().default(null),
37364
37516
  projectSlug: mrrlinProjectSlugSchema,
37517
+ questionOptions: mrrlinInboxQuestionOptionsSchema.nullable().default(null),
37365
37518
  readAt: external_exports.string().datetime().nullable(),
37366
37519
  status: mrrlinInboxItemStatusSchema,
37367
37520
  taskId: mrrlinTaskIdSchema.nullable()
37521
+ }).superRefine((value, context) => {
37522
+ if (value.kind !== "question" && value.questionOptions !== null) {
37523
+ context.addIssue({
37524
+ code: external_exports.ZodIssueCode.custom,
37525
+ message: "questionOptions are allowed only on question inbox items",
37526
+ path: ["questionOptions"]
37527
+ });
37528
+ }
37368
37529
  });
37369
37530
  var mrrlinInboxItemCreateSchema = external_exports.object({
37370
37531
  actionPayload: mrrlinInboxItemActionPayloadSchema.nullable().optional(),
@@ -37374,7 +37535,16 @@ var mrrlinInboxItemCreateSchema = external_exports.object({
37374
37535
  kind: mrrlinInboxItemKindSchema,
37375
37536
  origin: external_exports.string().min(1).optional(),
37376
37537
  planId: mrrlinPlanIdSchema.nullable().optional(),
37538
+ questionOptions: mrrlinInboxQuestionOptionsSchema.nullable().optional(),
37377
37539
  taskId: mrrlinTaskIdSchema.nullable().optional()
37540
+ }).superRefine((value, context) => {
37541
+ if (value.kind !== "question" && value.questionOptions != null) {
37542
+ context.addIssue({
37543
+ code: external_exports.ZodIssueCode.custom,
37544
+ message: "questionOptions are allowed only on question inbox items",
37545
+ path: ["questionOptions"]
37546
+ });
37547
+ }
37378
37548
  });
37379
37549
  var mrrlinInboxItemDecideSchema = external_exports.object({
37380
37550
  answer: external_exports.string().min(1).optional(),
@@ -38564,7 +38734,7 @@ Do NOT derail the current task - if the operator has an in-flight question, answ
38564
38734
  }
38565
38735
 
38566
38736
  // src/_generated/version.ts
38567
- var PKG_VERSION = "0.3.14";
38737
+ var PKG_VERSION = "0.3.20";
38568
38738
 
38569
38739
  // src/api-base-url.ts
38570
38740
  var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
@@ -38778,10 +38948,42 @@ function withTimeout(promise2, ms, message) {
38778
38948
  });
38779
38949
  }
38780
38950
  async function awaitTurnWithDeadline(opts) {
38951
+ const hasIdle = opts.idleTimeoutMs != null && opts.idleTimeoutMessage != null;
38952
+ const overallDeadlineAt = Date.now() + opts.timeoutMs;
38953
+ let overallTimer = null;
38954
+ let idleTimer = null;
38955
+ let settled = false;
38956
+ const clearTimers = () => {
38957
+ settled = true;
38958
+ if (overallTimer) clearTimeout(overallTimer);
38959
+ if (idleTimer) clearTimeout(idleTimer);
38960
+ overallTimer = null;
38961
+ idleTimer = null;
38962
+ };
38963
+ const activity = { touch: () => {
38964
+ } };
38965
+ const idleTimeoutMs = opts.idleTimeoutMs;
38966
+ const idleTimeoutMessage = opts.idleTimeoutMessage;
38967
+ const deadline = new Promise((_, reject) => {
38968
+ overallTimer = setTimeout(() => reject(new Error(opts.timeoutMessage)), opts.timeoutMs);
38969
+ if (idleTimeoutMs != null && idleTimeoutMessage != null) {
38970
+ const armIdle = () => {
38971
+ if (settled) return;
38972
+ if (idleTimer) clearTimeout(idleTimer);
38973
+ idleTimer = setTimeout(() => {
38974
+ reject(new Error(Date.now() >= overallDeadlineAt ? opts.timeoutMessage : idleTimeoutMessage));
38975
+ }, idleTimeoutMs);
38976
+ };
38977
+ activity.touch = armIdle;
38978
+ armIdle();
38979
+ }
38980
+ });
38781
38981
  try {
38782
- await withTimeout(opts.run(), opts.timeoutMs, opts.timeoutMessage);
38982
+ await Promise.race([opts.run(activity), deadline]);
38783
38983
  } catch (error51) {
38784
- const isDeadline = error51 instanceof Error && error51.message === opts.timeoutMessage;
38984
+ clearTimers();
38985
+ const message = error51 instanceof Error ? error51.message : String(error51);
38986
+ const isDeadline = message === opts.timeoutMessage || hasIdle && message === opts.idleTimeoutMessage;
38785
38987
  if (isDeadline) {
38786
38988
  try {
38787
38989
  await withTimeout(
@@ -38795,6 +38997,7 @@ async function awaitTurnWithDeadline(opts) {
38795
38997
  }
38796
38998
  throw error51;
38797
38999
  }
39000
+ clearTimers();
38798
39001
  }
38799
39002
 
38800
39003
  // src/worktree.ts
@@ -38987,9 +39190,12 @@ ${lines2.join("\n")}`;
38987
39190
  var DEFAULT_STALE_SECONDS = 300;
38988
39191
  var DEFAULT_TOUCH_INTERVAL_MS = 6e4;
38989
39192
  var DEFAULT_CLAIM_INTERVAL_SECONDS = 30;
38990
- var DEFAULT_EXEC_TURN_TIMEOUT_SECONDS = 900;
39193
+ var DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS = 600;
39194
+ var DEFAULT_EXEC_RUN_MAX_SECONDS = 3600;
38991
39195
  var DEFAULT_CONTEXT_MAX_ARTIFACTS = 50;
38992
39196
  var EXECUTION_TURN_TIMEOUT_PREFIX = "Execution turn did not complete within";
39197
+ var EXECUTION_TURN_STALL_PREFIX = "Execution turn stalled: no Codex activity within";
39198
+ var EXECUTION_TURN_BUDGET_PREFIX = "Execution turn exceeded the run wall-clock budget";
38993
39199
  var EXECUTION_TURN_TIMEOUT_RETRY_CODE = "execution_turn_timeout_retry";
38994
39200
  var EXECUTION_TURN_TIMEOUT_RETRY_SUMMARY = "Execution turn timed out; requeued for one automatic retry.";
38995
39201
  var EXECUTION_TURN_TIMEOUT_RETRY_CURSOR = "execution_turn_timeout_retry:1";
@@ -38999,14 +39205,26 @@ function readPositiveIntEnv(name, fallback) {
38999
39205
  const parsed = Number.parseInt(raw, 10);
39000
39206
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
39001
39207
  }
39002
- function execTurnTimeoutMs() {
39003
- return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_TIMEOUT_SECONDS) * 1e3;
39208
+ function execTurnIdleTimeoutMs() {
39209
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS", DEFAULT_EXEC_TURN_IDLE_TIMEOUT_SECONDS) * 1e3;
39210
+ }
39211
+ function execRunMaxMs() {
39212
+ return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS", DEFAULT_EXEC_RUN_MAX_SECONDS) * 1e3;
39213
+ }
39214
+ function explicitLegacyTurnTimeoutMs() {
39215
+ const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS ?? "").trim();
39216
+ if (!raw) return null;
39217
+ const parsed = Number.parseInt(raw, 10);
39218
+ return Number.isInteger(parsed) && parsed > 0 ? parsed * 1e3 : null;
39219
+ }
39220
+ function executionTurnStallMessage(idleMs) {
39221
+ return `${EXECUTION_TURN_STALL_PREFIX} ${idleMs}ms.`;
39004
39222
  }
39005
- function executionTurnTimeoutMessage(timeoutMs) {
39006
- return `${EXECUTION_TURN_TIMEOUT_PREFIX} ${timeoutMs}ms.`;
39223
+ function executionTurnBudgetMessage(budgetMs) {
39224
+ return `${EXECUTION_TURN_BUDGET_PREFIX} (${budgetMs}ms).`;
39007
39225
  }
39008
39226
  function isExecutionTurnTimeoutReason(reason) {
39009
- return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX);
39227
+ return reason.startsWith(EXECUTION_TURN_TIMEOUT_PREFIX) || reason.startsWith(EXECUTION_TURN_STALL_PREFIX);
39010
39228
  }
39011
39229
  function contextMaxArtifacts() {
39012
39230
  return readPositiveIntEnv("MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS", DEFAULT_CONTEXT_MAX_ARTIFACTS);
@@ -39159,11 +39377,15 @@ async function runHasLease(deps, projectSlug, runId, leaseId) {
39159
39377
  return false;
39160
39378
  }
39161
39379
  }
39162
- async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText) {
39380
+ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inputText, overallMs, phase) {
39163
39381
  const mapper = new CodexBridgeEventMapper();
39164
39382
  mapper.reset();
39165
39383
  let turnError = null;
39166
39384
  let interruptFailed = false;
39385
+ let touch = () => {
39386
+ };
39387
+ const turnStartedAt = Date.now();
39388
+ let lastActivityAt = turnStartedAt;
39167
39389
  const persist = async (event) => {
39168
39390
  if (event.type === "error") turnError = event.content;
39169
39391
  const kind = artifactKindForEvent(event);
@@ -39185,6 +39407,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39185
39407
  });
39186
39408
  let acceptedTurnId = null;
39187
39409
  const unsub = codex.onNotification((method, params) => {
39410
+ const notifThreadId = params?.threadId;
39411
+ if (!notifThreadId || notifThreadId === threadId) {
39412
+ lastActivityAt = Date.now();
39413
+ touch();
39414
+ }
39188
39415
  const events = mapper.mapNotification(
39189
39416
  { method, params },
39190
39417
  threadId
@@ -39192,9 +39419,8 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39192
39419
  for (const event of events) pending.push(persist(event));
39193
39420
  if (method === "turn/completed") {
39194
39421
  const p = params;
39195
- const notifThreadId = p?.threadId;
39196
39422
  const notifTurnId = p?.turn?.id;
39197
- const threadMatches = !notifThreadId || notifThreadId === threadId;
39423
+ const threadMatches = !p?.threadId || p.threadId === threadId;
39198
39424
  const turnMatches = !acceptedTurnId || !notifTurnId || notifTurnId === acceptedTurnId;
39199
39425
  if (threadMatches && turnMatches) resolveTurnEnded?.();
39200
39426
  }
@@ -39204,10 +39430,11 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39204
39430
  });
39205
39431
  }, deps.touchIntervalMs);
39206
39432
  touchTimer.unref?.();
39433
+ const idleMs = execTurnIdleTimeoutMs();
39207
39434
  try {
39208
- const timeoutMs = execTurnTimeoutMs();
39209
39435
  await awaitTurnWithDeadline({
39210
- run: async () => {
39436
+ run: async (activity) => {
39437
+ touch = activity.touch;
39211
39438
  const accepted = await codex.turn.start({
39212
39439
  threadId,
39213
39440
  input: [{ type: "text", text: inputText }]
@@ -39216,8 +39443,10 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39216
39443
  await turnEnded;
39217
39444
  },
39218
39445
  interrupt: () => codex.turn.interrupt({ threadId }),
39219
- timeoutMs,
39220
- timeoutMessage: executionTurnTimeoutMessage(timeoutMs),
39446
+ timeoutMs: overallMs,
39447
+ timeoutMessage: executionTurnBudgetMessage(overallMs),
39448
+ idleTimeoutMs: idleMs,
39449
+ idleTimeoutMessage: executionTurnStallMessage(idleMs),
39221
39450
  onInterruptFailed: () => {
39222
39451
  interruptFailed = true;
39223
39452
  }
@@ -39226,6 +39455,19 @@ async function driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, inp
39226
39455
  await Promise.all(pending);
39227
39456
  } catch (error51) {
39228
39457
  turnError = error51 instanceof Error ? error51.message : String(error51);
39458
+ if (turnError.startsWith(EXECUTION_TURN_STALL_PREFIX) || turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX)) {
39459
+ console.warn(
39460
+ JSON.stringify({
39461
+ event: "execution_turn_deadline",
39462
+ projectSlug,
39463
+ runId,
39464
+ phase,
39465
+ kind: turnError.startsWith(EXECUTION_TURN_BUDGET_PREFIX) ? "budget" : "stall",
39466
+ elapsedMs: Date.now() - turnStartedAt,
39467
+ sinceLastActivityMs: Date.now() - lastActivityAt
39468
+ })
39469
+ );
39470
+ }
39229
39471
  } finally {
39230
39472
  clearInterval(touchTimer);
39231
39473
  unsub();
@@ -39247,7 +39489,38 @@ async function driveRun(deps, projectSlug, runId) {
39247
39489
  return;
39248
39490
  }
39249
39491
  const leaseId = claimed.leaseId;
39492
+ const attemptDeadlineMs = Date.now() + execRunMaxMs();
39493
+ const remainingTurnBudgetMs = () => {
39494
+ const remaining = attemptDeadlineMs - Date.now();
39495
+ const legacy = explicitLegacyTurnTimeoutMs();
39496
+ return legacy != null ? Math.min(legacy, remaining) : remaining;
39497
+ };
39250
39498
  const task = await deps.client.getTask(projectSlug, claimed.taskId);
39499
+ const blockerTasks = task.blockedBy.length ? await Promise.all(task.blockedBy.map((blockerTaskId) => deps.client.getTask(projectSlug, blockerTaskId))) : [];
39500
+ const missingBlockers = task.blockedBy.filter((_, index) => !blockerTasks[index]);
39501
+ if (missingBlockers.length > 0) {
39502
+ await failRun(
39503
+ deps,
39504
+ projectSlug,
39505
+ task,
39506
+ runId,
39507
+ leaseId,
39508
+ `task_blocked: run ${runId} targets ${task.id} but blocker tasks could not be loaded: ${missingBlockers.join(", ")}`
39509
+ );
39510
+ return;
39511
+ }
39512
+ const activeBlockers = getActiveBlockedByIds(task.blockedBy, blockerTasks);
39513
+ if (activeBlockers.length > 0) {
39514
+ await failRun(
39515
+ deps,
39516
+ projectSlug,
39517
+ task,
39518
+ runId,
39519
+ leaseId,
39520
+ `task_blocked: run ${runId} targets ${task.id} but active blockers remain open: ${activeBlockers.join(", ")}`
39521
+ );
39522
+ return;
39523
+ }
39251
39524
  const compactEnabled = (process.env.MRRLIN_MCP_COMPACT_DISABLE ?? "").trim() !== "1";
39252
39525
  const context = await gatherContext(deps.client, projectSlug, task, runId);
39253
39526
  const bundle = renderContextBundle(
@@ -39269,7 +39542,11 @@ async function driveRun(deps, projectSlug, runId) {
39269
39542
  };
39270
39543
  if (claimed.codeExecution) {
39271
39544
  if (!checkout) {
39272
- const reason = `local_checkout_missing: run ${runId} requires code execution but no confirmed local checkout exists for project ${projectSlug}. Register a checkout via the operator tool before re-queuing.`;
39545
+ const reason = `local_checkout_missing: run ${runId} needs code execution, but project ${projectSlug} has no local git checkout registered on this machine, so there is nowhere to make the changes. Explain this to the operator in plain, non-technical language, then proactively help register one:
39546
+ 1. Search the usual locations (~/dev, ~/code, ~/work, ~/projects) for a clone of this project's GitHub repo, matching by its \`git remote origin\` URL.
39547
+ 2. If you find a likely match, tell the operator where you think it is and ask them to confirm \u2014 e.g. "For code changes I need this project's repo checked out here. I think it's at <path> \u2014 should I register that one, or do you want to point me at a different path?"
39548
+ 3. When the operator confirms a path, call register_local_checkout({ projectSlug: "${projectSlug}", path: "<absolute path>", repo: "<this project's GitHub repo>" }).
39549
+ 4. If no candidate is found, ask the operator for the absolute path to the checkout (or to clone the repo first), then register it. Once a checkout is registered, the run can be re-queued.`;
39273
39550
  await failRun(deps, projectSlug, task, runId, leaseId, reason);
39274
39551
  return;
39275
39552
  }
@@ -39284,6 +39561,7 @@ async function driveRun(deps, projectSlug, runId) {
39284
39561
  runId,
39285
39562
  defaultBranch: await getDefaultBranch(projectSlug)
39286
39563
  });
39564
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39287
39565
  await deps.client.updateExecutionRun(projectSlug, runId, {
39288
39566
  worktreeId: worktreePath,
39289
39567
  branch: worktreeBranch(runId)
@@ -39334,11 +39612,17 @@ async function driveRun(deps, projectSlug, runId) {
39334
39612
  ephemeral: null
39335
39613
  },
39336
39614
  onThreadIdChanged: async (newId) => {
39615
+ if (!await runHasLease(deps, projectSlug, runId, leaseId)) return;
39337
39616
  await deps.client.updateTask(projectSlug, task.id, { directorThreadId: newId });
39338
39617
  }
39339
39618
  });
39340
39619
  if (!resumed) {
39341
- const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle);
39620
+ const primingBudgetMs = remainingTurnBudgetMs();
39621
+ if (primingBudgetMs <= 0) {
39622
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39623
+ return;
39624
+ }
39625
+ const primed = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, bundle, primingBudgetMs, "priming");
39342
39626
  if (!primed.ok) {
39343
39627
  await resetWedgedThread(deps, projectSlug, task.id, primed.interruptFailed);
39344
39628
  await failRun(deps, projectSlug, task, runId, leaseId, primed.error);
@@ -39348,7 +39632,12 @@ async function driveRun(deps, projectSlug, runId) {
39348
39632
  const executionPrompt = resumed ? `${bundle}
39349
39633
 
39350
39634
  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);
39635
+ const workBudgetMs = remainingTurnBudgetMs();
39636
+ if (workBudgetMs <= 0) {
39637
+ await failRun(deps, projectSlug, task, runId, leaseId, executionTurnBudgetMessage(execRunMaxMs()));
39638
+ return;
39639
+ }
39640
+ const outcome = await driveTurn(deps, codex, projectSlug, runId, leaseId, threadId, executionPrompt, workBudgetMs, "work");
39352
39641
  if (!outcome.ok) {
39353
39642
  await resetWedgedThread(deps, projectSlug, task.id, outcome.interruptFailed);
39354
39643
  await failRun(deps, projectSlug, task, runId, leaseId, outcome.error);
@@ -39903,9 +40192,15 @@ function saveThreadIndex(stateDir, entries) {
39903
40192
  function createInactivityWatchdog(opts) {
39904
40193
  const setT = opts.setTimeoutFn ?? setTimeout;
39905
40194
  const clearT = opts.clearTimeoutFn ?? clearTimeout;
40195
+ const now = opts.now ?? Date.now;
40196
+ const suspended = opts.suspendedMs ?? (() => 0);
39906
40197
  let idleTimer = null;
39907
40198
  let maxTimer = null;
39908
40199
  let done = false;
40200
+ const startAt = now();
40201
+ const suspendedAtStart = suspended();
40202
+ let lastPokeAt = startAt;
40203
+ let suspendedAtLastPoke = suspendedAtStart;
39909
40204
  const expire = (reason) => {
39910
40205
  if (done) return;
39911
40206
  done = true;
@@ -39913,16 +40208,41 @@ function createInactivityWatchdog(opts) {
39913
40208
  if (maxTimer) clearT(maxTimer);
39914
40209
  opts.onExpire(reason);
39915
40210
  };
39916
- idleTimer = setT(() => expire("idle"), opts.idleMs);
39917
- idleTimer.unref?.();
39918
- maxTimer = setT(() => expire("max"), opts.maxMs);
39919
- maxTimer.unref?.();
40211
+ const armIdle = (ms) => {
40212
+ idleTimer = setT(onIdleDeadline, Math.max(1, ms));
40213
+ idleTimer.unref?.();
40214
+ };
40215
+ const armMax = (ms) => {
40216
+ maxTimer = setT(onMaxDeadline, Math.max(1, ms));
40217
+ maxTimer.unref?.();
40218
+ };
40219
+ const onIdleDeadline = () => {
40220
+ if (done) return;
40221
+ const awakeSilence = now() - lastPokeAt - (suspended() - suspendedAtLastPoke);
40222
+ if (awakeSilence < opts.idleMs) {
40223
+ armIdle(opts.idleMs - awakeSilence);
40224
+ return;
40225
+ }
40226
+ expire("idle");
40227
+ };
40228
+ const onMaxDeadline = () => {
40229
+ if (done) return;
40230
+ const awakeElapsed = now() - startAt - (suspended() - suspendedAtStart);
40231
+ if (awakeElapsed < opts.maxMs) {
40232
+ armMax(opts.maxMs - awakeElapsed);
40233
+ return;
40234
+ }
40235
+ expire("max");
40236
+ };
40237
+ armIdle(opts.idleMs);
40238
+ armMax(opts.maxMs);
39920
40239
  return {
39921
40240
  poke: () => {
39922
40241
  if (done) return;
39923
40242
  if (idleTimer) clearT(idleTimer);
39924
- idleTimer = setT(() => expire("idle"), opts.idleMs);
39925
- idleTimer.unref?.();
40243
+ lastPokeAt = now();
40244
+ suspendedAtLastPoke = suspended();
40245
+ armIdle(opts.idleMs);
39926
40246
  },
39927
40247
  cancel: () => {
39928
40248
  done = true;
@@ -44098,6 +44418,7 @@ function createBridgeLogger(stateDir, env = process.env) {
44098
44418
  }
44099
44419
 
44100
44420
  // src/director-bridge.ts
44421
+ var JSON_GPT_MODEL_REF_RE = /"(?:slug|model)"\s*:\s*"(gpt-[a-z0-9.-]+)"/gi;
44101
44422
  var DEFAULT_DIRECTOR_BRIDGE_ALLOWED_ORIGINS = [
44102
44423
  "http://localhost:*",
44103
44424
  "http://127.0.0.1:*",
@@ -44118,6 +44439,139 @@ function normalizeModel(value) {
44118
44439
  const trimmed = value.trim();
44119
44440
  return trimmed ? trimmed : null;
44120
44441
  }
44442
+ function isSelectableModelSlug(value) {
44443
+ return typeof value === "string" && value.startsWith("gpt-");
44444
+ }
44445
+ function humanizeModelSlug(slug) {
44446
+ if (!slug.startsWith("gpt-")) return slug;
44447
+ const rest = slug.slice(4);
44448
+ if (!rest) return "GPT";
44449
+ const [version2, ...variants] = rest.split("-").filter(Boolean);
44450
+ if (variants.length === 0) return `GPT-${version2}`;
44451
+ return `GPT-${version2} ${variants.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ")}`;
44452
+ }
44453
+ function mapBridgeModel(raw) {
44454
+ const model = (raw.model ?? raw.id ?? "").trim();
44455
+ if (!model) return null;
44456
+ return {
44457
+ id: (raw.id ?? model).trim() || model,
44458
+ model,
44459
+ displayName: (raw.displayName ?? model).trim() || model,
44460
+ description: raw.description ?? "",
44461
+ supportedReasoningEfforts: (raw.supportedReasoningEfforts ?? []).map((entry) => ({
44462
+ reasoningEffort: entry.reasoningEffort,
44463
+ description: entry.description ?? ""
44464
+ })),
44465
+ defaultReasoningEffort: raw.defaultReasoningEffort ?? null,
44466
+ isDefault: raw.isDefault === true
44467
+ };
44468
+ }
44469
+ function addBridgeModel(target, model) {
44470
+ if (!model) return;
44471
+ if (!target.has(model.model)) target.set(model.model, model);
44472
+ }
44473
+ function readTextIfExists(filePath) {
44474
+ try {
44475
+ return import_node_fs10.default.readFileSync(filePath, "utf8");
44476
+ } catch {
44477
+ return null;
44478
+ }
44479
+ }
44480
+ function readKnownGptSlugsFromTomlFile(filePath) {
44481
+ const raw = readTextIfExists(filePath);
44482
+ if (!raw) return [];
44483
+ return Array.from(raw.matchAll(/^\s*model\s*=\s*"([^"]+)"/gm)).map((match) => match[1]?.trim() ?? "").filter(isSelectableModelSlug);
44484
+ }
44485
+ function readCodexFallbackModels() {
44486
+ const codexHome = process.env.CODEX_HOME?.trim() || import_node_path11.default.join(import_node_os7.default.homedir(), ".codex");
44487
+ const models = /* @__PURE__ */ new Map();
44488
+ const cachePath = import_node_path11.default.join(codexHome, "models_cache.json");
44489
+ const cacheRaw = readTextIfExists(cachePath);
44490
+ if (cacheRaw) {
44491
+ try {
44492
+ const parsed = JSON.parse(cacheRaw);
44493
+ if (Array.isArray(parsed.models)) {
44494
+ for (const entry of parsed.models) {
44495
+ if (!entry || typeof entry !== "object") continue;
44496
+ const cached2 = entry;
44497
+ const slug = typeof cached2.slug === "string" ? cached2.slug.trim() : "";
44498
+ if (!isSelectableModelSlug(slug)) continue;
44499
+ const supportedReasoningEfforts = Array.isArray(cached2.supported_reasoning_levels) ? cached2.supported_reasoning_levels.flatMap((level) => {
44500
+ if (!level || typeof level !== "object") return [];
44501
+ const option = level;
44502
+ if (typeof option.effort !== "string" || !option.effort.trim()) return [];
44503
+ return [{ reasoningEffort: option.effort, description: typeof option.description === "string" ? option.description : "" }];
44504
+ }) : [];
44505
+ addBridgeModel(
44506
+ models,
44507
+ mapBridgeModel({
44508
+ id: slug,
44509
+ model: slug,
44510
+ displayName: typeof cached2.display_name === "string" ? cached2.display_name : humanizeModelSlug(slug),
44511
+ description: typeof cached2.description === "string" ? cached2.description : "",
44512
+ supportedReasoningEfforts,
44513
+ defaultReasoningEffort: typeof cached2.default_reasoning_level === "string" ? cached2.default_reasoning_level : null,
44514
+ isDefault: false
44515
+ })
44516
+ );
44517
+ }
44518
+ }
44519
+ } catch {
44520
+ }
44521
+ for (const match of cacheRaw.matchAll(JSON_GPT_MODEL_REF_RE)) {
44522
+ const slug = match[1]?.trim().toLowerCase() ?? "";
44523
+ if (!isSelectableModelSlug(slug)) continue;
44524
+ addBridgeModel(
44525
+ models,
44526
+ mapBridgeModel({
44527
+ id: slug,
44528
+ model: slug,
44529
+ displayName: humanizeModelSlug(slug),
44530
+ description: "",
44531
+ supportedReasoningEfforts: [],
44532
+ defaultReasoningEffort: null,
44533
+ isDefault: false
44534
+ })
44535
+ );
44536
+ }
44537
+ }
44538
+ for (const slug of readKnownGptSlugsFromTomlFile(import_node_path11.default.join(codexHome, "config.toml"))) {
44539
+ addBridgeModel(
44540
+ models,
44541
+ mapBridgeModel({
44542
+ id: slug,
44543
+ model: slug,
44544
+ displayName: humanizeModelSlug(slug),
44545
+ description: "",
44546
+ supportedReasoningEfforts: [],
44547
+ defaultReasoningEffort: null,
44548
+ isDefault: false
44549
+ })
44550
+ );
44551
+ }
44552
+ const automationsDir = import_node_path11.default.join(codexHome, "automations");
44553
+ try {
44554
+ for (const entry of import_node_fs10.default.readdirSync(automationsDir, { withFileTypes: true })) {
44555
+ if (!entry.isDirectory()) continue;
44556
+ for (const slug of readKnownGptSlugsFromTomlFile(import_node_path11.default.join(automationsDir, entry.name, "automation.toml"))) {
44557
+ addBridgeModel(
44558
+ models,
44559
+ mapBridgeModel({
44560
+ id: slug,
44561
+ model: slug,
44562
+ displayName: humanizeModelSlug(slug),
44563
+ description: "",
44564
+ supportedReasoningEfforts: [],
44565
+ defaultReasoningEffort: null,
44566
+ isDefault: false
44567
+ })
44568
+ );
44569
+ }
44570
+ }
44571
+ } catch {
44572
+ }
44573
+ return [...models.values()];
44574
+ }
44121
44575
  function neutralCheckoutCwd(stateDir) {
44122
44576
  const dir = import_node_path11.default.join(stateDir, "no-checkout");
44123
44577
  try {
@@ -44145,13 +44599,16 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44145
44599
  "request behind the task (pure system bookkeeping) \u2014 note that omitting it makes the gate fail-closed.",
44146
44600
  "",
44147
44601
  "Spec authoring contract \u2014 when creating or updating a `Specs/{id}` wiki page:",
44602
+ "- BEGIN the page with YAML frontmatter naming the gated task: a first line `---`, then",
44603
+ " `task: {taskId}` (the task this spec gates, e.g. `task: GT-1023`), then `---`. Never change it",
44604
+ " to another task id \u2014 it is the machine-readable identity the consensus gate validates against.",
44148
44605
  "- In `## Specification`, write each requirement as `### Requirement: <name>` followed by one",
44149
44606
  " SHALL sentence stating the externally visible behavior.",
44150
44607
  "- Give every requirement at least one `#### Scenario: <name>` with `- WHEN ...` / `- THEN ...`",
44151
44608
  " bullets (`- AND ...` as needed). Scenarios make the spec verifiable.",
44152
44609
  "- Keep behavior contracts in the spec; implementation details belong in `## Implementation Plan`.",
44153
44610
  "- Apply this format ONLY to Specs/{id} pages; do not retro-fit it onto other wiki page types.",
44154
- "- Spec consensus runs ASYNCHRONOUSLY. Call `run_spec_consensus` for a `Specs/{id}` page; it returns IMMEDIATELY. On `already_converged`, the spec is already marked ready \u2014 proceed straight to `create_execution_run`; do NOT call `mark_spec_ready` and do NOT re-run consensus. On `queued` or `already_running`, tell the user it was launched and END your turn \u2014 do NOT poll or call it again; the result arrives in the inbox (an `info` item on CONVERGED, a `handoff` item if it was blocked/failed). When a consensus `info` item says CONVERGED, proceed to `create_execution_run`. When a `handoff` says it was blocked, address the recorded issues in the spec (any edit re-arms the gate) and call `run_spec_consensus` once more.",
44611
+ "- Spec consensus runs ASYNCHRONOUSLY. Call `run_spec_consensus` for a `Specs/{id}` page; it returns IMMEDIATELY. On `already_converged`, the spec is already marked ready \u2014 proceed straight to `create_execution_run`; do NOT call `mark_spec_ready` and do NOT re-run consensus. On `queued` or `already_running`, tell the user it was launched and END your turn \u2014 do NOT poll or call it again; the result arrives in the inbox (an `info` item on CONVERGED, a `handoff` item if it was blocked/failed). When a consensus `info` item says CONVERGED, proceed to `create_execution_run`. When a `handoff` says it was blocked, address the recorded issues in the spec (any edit re-arms the gate) and call `run_spec_consensus` once more. For a NON-code task (domain seo/marketing/content, ADR 0020) the spec is review-only \u2014 do NOT call `create_execution_run` (non-code execution is not yet supported and fail-closes); tell the operator the spec was reviewed.",
44155
44612
  '- When a task needs a browser (login flows, UI verification/clicks, scraping), pass `prerequisites:[{mcp:"mrrlin-browser",reason:"..."}]` to `create_execution_run`. If that run later fails into a handoff, the operator gets a one-click "Launch browser & resume" that brings up the dedicated browser as the turn starts.',
44156
44613
  "- When you actually drive a browser (calling `browser_navigate`, `browser_click`, `browser_snapshot`, `browser_type`, etc.), invoke those tools from the **`mrrlin-browser`** MCP server ONLY. Do NOT use tools from any other browser MCP (`playwright`, `chrome-devtools`, `puppeteer`, etc.) even if they are also connected on this operator's box \u2014 those use a shared/default Playwright profile that is fragile across runs. Only `mrrlin-browser` uses this project's dedicated Chromium + persistent operator profile (`~/.mrrlin/browser/profile`). If your host exposes multiple browser MCPs with overlapping tool names, prefer the `mrrlin-browser` server unconditionally.",
44157
44614
  "",
@@ -44220,8 +44677,12 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44220
44677
  " assumption on the spec page.",
44221
44678
  "- For any such task-affecting question, ALSO bank it durably before your final reply:",
44222
44679
  ' `create_inbox_item` with `kind="question"`, `taskId` set, `directorSessionId` from the',
44223
- " Context JSON, and `body` = the exact question. (The server dedups identical open",
44224
- " questions; do not supply a dedup key.)",
44680
+ " Context JSON, `body` = the exact question, and `questionOptions` = 2-3 concrete answer",
44681
+ " options. Each option MUST include a short `label`, a concise `description`, and a",
44682
+ " self-contained durable `answer`; you MAY mark at most one option as `recommended` when",
44683
+ " you explicitly prefer a path. The operator always has a custom-answer path in the UI, so",
44684
+ " your options should guide, not trap. (The server dedups identical open questions; do not",
44685
+ " supply a dedup key.)",
44225
44686
  "- When the operator answers in chat, write the answer into the Specs/{taskId}",
44226
44687
  ' Clarifications section, then `decide_inbox_item` the question with `verdict="acknowledged"`',
44227
44688
  " and `answer` = the resolved answer.",
@@ -44552,6 +45013,24 @@ function attachBridgeWss(server, opts) {
44552
45013
  return;
44553
45014
  }
44554
45015
  const span = { id: "conn", at: 0 };
45016
+ let isAlive = true;
45017
+ socket.on("pong", () => {
45018
+ isAlive = true;
45019
+ });
45020
+ const pingTimer = setInterval(() => {
45021
+ if (socket.readyState !== socket.OPEN) return;
45022
+ if (!isAlive) {
45023
+ socket.terminate();
45024
+ return;
45025
+ }
45026
+ isAlive = false;
45027
+ try {
45028
+ socket.ping();
45029
+ } catch {
45030
+ }
45031
+ }, wsPingMs());
45032
+ pingTimer.unref?.();
45033
+ socket.on("close", () => clearInterval(pingTimer));
44555
45034
  const send = (msg) => {
44556
45035
  if (socket.readyState !== socket.OPEN) return;
44557
45036
  logger?.logOut({
@@ -44595,6 +45074,20 @@ function attachBridgeWss(server, opts) {
44595
45074
  }
44596
45075
  var TurnInterruptedError = class extends Error {
44597
45076
  };
45077
+ var DEFAULT_WS_PING_SECONDS = 30;
45078
+ function wsPingMs() {
45079
+ const raw = (process.env.MRRLIN_DIRECTOR_BRIDGE_WS_PING_SECONDS ?? "").trim();
45080
+ const parsed = Number.parseInt(raw, 10);
45081
+ return (Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_WS_PING_SECONDS) * 1e3;
45082
+ }
45083
+ async function waitFor(pred, timeoutMs) {
45084
+ const deadline = Date.now() + timeoutMs;
45085
+ while (Date.now() < deadline) {
45086
+ if (pred()) return true;
45087
+ await new Promise((r) => setTimeout(r, 25));
45088
+ }
45089
+ return pred();
45090
+ }
44598
45091
  function attachSeqMetadata(msg, seq, turnId) {
44599
45092
  if (msg.type === "event" || msg.type === "rateLimits" || msg.type === "error") {
44600
45093
  return { ...msg, seq, turnId };
@@ -44653,11 +45146,11 @@ function createBridgeMessageHandler(deps) {
44653
45146
  logResumeRejected(req.directorSessionId, "superseded");
44654
45147
  return;
44655
45148
  }
44656
- const tail2 = eventsAfter(buf, req.lastSeq);
45149
+ let tail2 = eventsAfter(buf, req.lastSeq);
45150
+ let truncated = false;
44657
45151
  if (tail2 === null) {
44658
- sendMsg({ type: "resume", status: "no-buffer", reason: "evicted" });
44659
- logResumeRejected(req.directorSessionId, "evicted");
44660
- return;
45152
+ truncated = true;
45153
+ tail2 = [...buf.events];
44661
45154
  }
44662
45155
  const result = transition(buf.state, { kind: "resume", epochMatch: true });
44663
45156
  if (result.next === "invalid") {
@@ -44669,7 +45162,14 @@ function createBridgeMessageHandler(deps) {
44669
45162
  buf.replaying = true;
44670
45163
  deps.bindSessionSink?.(req.directorSessionId, send);
44671
45164
  boundSessions.add(req.directorSessionId);
44672
- if (buf.triggerGrace) socket.once("close", buf.triggerGrace);
45165
+ if (buf.triggerDetach) {
45166
+ const detach = buf.triggerDetach;
45167
+ const epochAtBind = buf.bindEpoch;
45168
+ socket.once("close", () => {
45169
+ if (buf.bindEpoch !== epochAtBind) return;
45170
+ detach();
45171
+ });
45172
+ }
44673
45173
  const willStreamLive = result.next === "live";
44674
45174
  sendMsg({
44675
45175
  type: "resume",
@@ -44682,6 +45182,14 @@ function createBridgeMessageHandler(deps) {
44682
45182
  eventCount: tail2.length,
44683
45183
  willStreamLive
44684
45184
  });
45185
+ if (truncated) {
45186
+ sendMsg({
45187
+ type: "replay-truncated",
45188
+ directorSessionId: req.directorSessionId,
45189
+ turnId: buf.turnId,
45190
+ droppedBelowSeq: tail2[0]?.seq ?? buf.lastSeq
45191
+ });
45192
+ }
44685
45193
  for (const { msg: bufferedMsg } of tail2) sendMsg(bufferedMsg);
44686
45194
  for (const m of buf.outboundQueue) sendMsg(m);
44687
45195
  buf.outboundQueue.length = 0;
@@ -44760,26 +45268,36 @@ function createBridgeMessageHandler(deps) {
44760
45268
  let models = null;
44761
45269
  let rateLimits = null;
44762
45270
  let bootstrapError;
45271
+ const fallbackModels = readCodexFallbackModels();
44763
45272
  try {
44764
45273
  const codexClient2 = await deps.acquireCodex();
44765
45274
  try {
44766
45275
  try {
44767
- const res = await codexClient2.model.list({});
45276
+ const res = await codexClient2.model.list({ includeHidden: true });
44768
45277
  const raw = res.data ?? res.models ?? [];
44769
- models = raw.filter((m) => m.hidden !== true).map((m) => ({
44770
- id: m.id,
44771
- model: m.model ?? m.id,
44772
- displayName: m.displayName ?? m.model ?? m.id,
44773
- description: m.description ?? "",
44774
- supportedReasoningEfforts: (m.supportedReasoningEfforts ?? []).map((o) => ({
44775
- reasoningEffort: o.reasoningEffort,
44776
- description: o.description ?? ""
44777
- })),
44778
- defaultReasoningEffort: m.defaultReasoningEffort ?? null,
44779
- isDefault: m.isDefault === true
44780
- }));
45278
+ const mergedModels = /* @__PURE__ */ new Map();
45279
+ for (const m of raw) {
45280
+ const modelSlug = (m.model ?? m.id ?? "").trim();
45281
+ if (!modelSlug) continue;
45282
+ if (m.hidden === true && (modelSlug.startsWith("codex-") || !modelSlug.startsWith("gpt-"))) continue;
45283
+ addBridgeModel(
45284
+ mergedModels,
45285
+ mapBridgeModel({
45286
+ id: m.id,
45287
+ model: m.model ?? m.id,
45288
+ displayName: m.displayName ?? m.model ?? m.id,
45289
+ description: m.description ?? "",
45290
+ supportedReasoningEfforts: m.supportedReasoningEfforts ?? [],
45291
+ defaultReasoningEffort: m.defaultReasoningEffort ?? null,
45292
+ isDefault: m.isDefault === true
45293
+ })
45294
+ );
45295
+ }
45296
+ for (const fallbackModel of fallbackModels) addBridgeModel(mergedModels, fallbackModel);
45297
+ models = [...mergedModels.values()];
44781
45298
  } catch (error51) {
44782
45299
  bootstrapError = error51 instanceof Error ? error51.message : String(error51);
45300
+ models = fallbackModels.length > 0 ? fallbackModels : null;
44783
45301
  }
44784
45302
  try {
44785
45303
  const res = await codexClient2.account.readRateLimits();
@@ -44792,6 +45310,7 @@ function createBridgeMessageHandler(deps) {
44792
45310
  }
44793
45311
  } catch (error51) {
44794
45312
  bootstrapError = error51 instanceof Error ? error51.message : String(error51);
45313
+ models = fallbackModels.length > 0 ? fallbackModels : null;
44795
45314
  }
44796
45315
  sendForSpan({ type: "bootstrap", models, rateLimits, ...deps.bridgeInstanceId ? { bridgeInstanceId: deps.bridgeInstanceId } : {}, ...bootstrapError ? { error: bootstrapError } : {} });
44797
45316
  return;
@@ -44800,6 +45319,20 @@ function createBridgeMessageHandler(deps) {
44800
45319
  handleResumeRequest(msg, sendForSpan);
44801
45320
  return;
44802
45321
  }
45322
+ if (msg.type === "ack") {
45323
+ const ack = msg;
45324
+ const sessionId = typeof ack.directorSessionId === "string" ? ack.directorSessionId.trim() : "";
45325
+ const ackThread = sessionId ? deps.threads.get(sessionId) : void 0;
45326
+ if (ackThread?.replay && typeof ack.turnId === "string" && typeof ack.lastSeq === "number" && recordAck(ackThread.replay, ack.turnId, ack.lastSeq, Date.now())) {
45327
+ logCtx?.logger.logOut({
45328
+ spanId,
45329
+ sessionId,
45330
+ type: "<result-acked>",
45331
+ payload: { sessionId, turnId: ack.turnId, lastSeq: ack.lastSeq }
45332
+ });
45333
+ }
45334
+ return;
45335
+ }
44803
45336
  if (msg.type === "interrupt") {
44804
45337
  const directorSessionId2 = (msg.directorSessionId ?? "").trim();
44805
45338
  const existing = directorSessionId2 ? deps.threads.get(directorSessionId2) : void 0;
@@ -44808,12 +45341,11 @@ function createBridgeMessageHandler(deps) {
44808
45341
  return;
44809
45342
  }
44810
45343
  const interruptBuf = existing.replay;
44811
- if (interruptBuf && interruptBuf.state === "grace") {
44812
- const stopResult = transition("grace", { kind: "stop" });
45344
+ if (interruptBuf && interruptBuf.state === "detached") {
45345
+ const stopResult = transition("detached", { kind: "stop" });
44813
45346
  if (stopResult.next !== "invalid") {
44814
45347
  interruptBuf.state = stopResult.next;
44815
- cancelGrace(interruptBuf);
44816
- recordInterrupted(interruptBuf);
45348
+ recordInterrupted(interruptBuf, Date.now(), deps.suspendedMs?.() ?? 0);
44817
45349
  }
44818
45350
  }
44819
45351
  const codexClient2 = await deps.acquireCodex();
@@ -44875,6 +45407,36 @@ function createBridgeMessageHandler(deps) {
44875
45407
  }
44876
45408
  const codexClient = await deps.acquireCodex();
44877
45409
  let thread = deps.threads.get(directorSessionId);
45410
+ if (thread?.inFlight && msg.takeover === true) {
45411
+ const takenOver = thread;
45412
+ const oldBuf = takenOver.replay;
45413
+ if (oldBuf && (oldBuf.state === "live" || oldBuf.state === "detached")) {
45414
+ const r = transition(oldBuf.state, { kind: "stop" });
45415
+ if (r.next !== "invalid") {
45416
+ oldBuf.state = r.next;
45417
+ recordInterrupted(oldBuf, Date.now(), deps.suspendedMs?.() ?? 0);
45418
+ }
45419
+ }
45420
+ takenOver.cancel?.();
45421
+ void withTimeout(
45422
+ codexClient.turn.interrupt({ threadId: takenOver.threadId }),
45423
+ DEFAULT_TURN_INTERRUPT_TIMEOUT_MS,
45424
+ "interrupt timed out"
45425
+ ).catch(() => {
45426
+ });
45427
+ const cleared = await waitFor(() => !takenOver.inFlight, DEFAULT_TURN_INTERRUPT_TIMEOUT_MS);
45428
+ if (!cleared) {
45429
+ deps.threads.delete(directorSessionId);
45430
+ deps.persistThreads();
45431
+ thread = void 0;
45432
+ }
45433
+ logCtx?.logger.logOut({
45434
+ spanId,
45435
+ sessionId: directorSessionId,
45436
+ type: "<turn-takeover>",
45437
+ payload: { sessionId: directorSessionId, clean: cleared, oldTurnId: oldBuf?.turnId ?? null }
45438
+ });
45439
+ }
44878
45440
  const desiredModel = normalizeModel(msg.model);
44879
45441
  const desiredEffort = normalizeReasoningEffort(msg.reasoningEffort);
44880
45442
  try {
@@ -44932,7 +45494,13 @@ function createBridgeMessageHandler(deps) {
44932
45494
  return;
44933
45495
  }
44934
45496
  if (thread.inFlight) {
44935
- sendForSpan({ type: "error", directorSessionId, error: "Director session is busy." });
45497
+ sendForSpan({
45498
+ type: "error",
45499
+ directorSessionId,
45500
+ code: "busy",
45501
+ ...thread.replay ? { inFlightTurnId: thread.replay.turnId } : {},
45502
+ error: "Director session is busy."
45503
+ });
44936
45504
  deps.releaseCodex();
44937
45505
  return;
44938
45506
  }
@@ -44940,7 +45508,8 @@ function createBridgeMessageHandler(deps) {
44940
45508
  thread.replay = createReplayBuffer({
44941
45509
  turnId: (0, import_node_crypto5.randomUUID)(),
44942
45510
  spanId,
44943
- ...deps.turnGraceMs !== void 0 ? { graceMs: deps.turnGraceMs } : {}
45511
+ ...deps.retentionMs !== void 0 ? { retentionMs: deps.retentionMs } : {},
45512
+ ...deps.ackLingerMs !== void 0 ? { ackLingerMs: deps.ackLingerMs } : {}
44944
45513
  });
44945
45514
  thread.lastUsedAt = Date.now();
44946
45515
  const threadId = thread.threadId;
@@ -45031,6 +45600,7 @@ function createBridgeMessageHandler(deps) {
45031
45600
  const watchdog = createInactivityWatchdog({
45032
45601
  idleMs,
45033
45602
  maxMs,
45603
+ ...deps.suspendedMs ? { suspendedMs: deps.suspendedMs } : {},
45034
45604
  onExpire: (reason) => {
45035
45605
  rejectWatchdog?.(
45036
45606
  new Error(
@@ -45039,48 +45609,36 @@ function createBridgeMessageHandler(deps) {
45039
45609
  );
45040
45610
  void withTimeout(codexClient.turn.interrupt({ threadId }), DEFAULT_TURN_INTERRUPT_TIMEOUT_MS, "interrupt timed out").catch(
45041
45611
  () => {
45042
- deps.threads.delete(directorSessionId);
45043
- deps.persistThreads();
45612
+ if (deps.threads.get(directorSessionId) === thread) {
45613
+ deps.threads.delete(directorSessionId);
45614
+ deps.persistThreads();
45615
+ }
45044
45616
  }
45045
45617
  );
45046
45618
  }
45047
45619
  });
45620
+ const turnBuf = thread.replay;
45048
45621
  const onSocketClose = () => {
45049
45622
  const buf = thread.replay;
45050
- if (!buf || buf.state !== "live") return;
45051
- const r = transition("live", { kind: "socket_close" });
45052
- if (r.next === "invalid") return;
45623
+ if (!buf || buf !== turnBuf) return;
45624
+ const r = transition(buf.state, { kind: "socket_close" });
45625
+ if (r.next === "invalid" || r.next === buf.state) return;
45053
45626
  buf.state = r.next;
45054
45627
  logCtx?.logger.logOut({
45055
45628
  spanId,
45056
45629
  sessionId: directorSessionId,
45057
- type: "<resume-grace-started>",
45058
- payload: { sessionId: directorSessionId, turnId: buf.turnId, graceMs: buf.graceMs }
45059
- });
45060
- enterGrace(buf, () => {
45061
- const expired = transition("grace", { kind: "grace_expire" });
45062
- if (expired.next === "invalid") return;
45063
- buf.state = expired.next;
45064
- recordInterrupted(buf);
45065
- logCtx?.logger.logOut({
45066
- spanId,
45067
- sessionId: directorSessionId,
45068
- type: "<grace-expired>",
45069
- payload: { sessionId: directorSessionId, turnId: buf.turnId, lastSeq: buf.lastSeq }
45070
- });
45071
- rejectWatchdog?.(new TurnInterruptedError());
45072
- void withTimeout(
45073
- codexClient.turn.interrupt({ threadId }),
45074
- DEFAULT_TURN_INTERRUPT_TIMEOUT_MS,
45075
- "interrupt timed out"
45076
- ).catch(() => {
45077
- deps.threads.delete(directorSessionId);
45078
- deps.persistThreads();
45079
- });
45630
+ type: "<buffer-detached>",
45631
+ payload: { sessionId: directorSessionId, turnId: buf.turnId, lastSeq: buf.lastSeq }
45080
45632
  });
45081
45633
  };
45082
- socket.once("close", onSocketClose);
45083
- if (thread.replay) thread.replay.triggerGrace = onSocketClose;
45634
+ {
45635
+ const epochAtBind = turnBuf?.bindEpoch ?? 0;
45636
+ socket.once("close", () => {
45637
+ if (turnBuf && turnBuf.bindEpoch !== epochAtBind) return;
45638
+ onSocketClose();
45639
+ });
45640
+ }
45641
+ if (thread.replay) thread.replay.triggerDetach = onSocketClose;
45084
45642
  pokeActivity = () => watchdog.poke();
45085
45643
  const runSequence = (async () => {
45086
45644
  const turnInput = [];
@@ -45129,10 +45687,20 @@ ${effectiveText}` : effectiveText;
45129
45687
  sendInTurn({ type: "event", directorSessionId, event });
45130
45688
  }
45131
45689
  } finally {
45132
- if (thread.replay && thread.replay.state === "live") {
45133
- markCompleted(thread.replay, Date.now());
45690
+ if (thread.replay && (thread.replay.state === "live" || thread.replay.state === "detached")) {
45691
+ markCompleted(thread.replay, Date.now(), deps.suspendedMs?.() ?? 0);
45692
+ logCtx?.logger.logOut({
45693
+ spanId,
45694
+ sessionId: directorSessionId,
45695
+ type: "<result-retained>",
45696
+ payload: {
45697
+ sessionId: directorSessionId,
45698
+ turnId: thread.replay.turnId,
45699
+ lastSeq: thread.replay.lastSeq,
45700
+ bytes: thread.replay.bytesUsed
45701
+ }
45702
+ });
45134
45703
  }
45135
- socket.off("close", onSocketClose);
45136
45704
  watchdog.cancel();
45137
45705
  unsub();
45138
45706
  thread.cancel = null;
@@ -45439,6 +46007,7 @@ async function startDirectorBridge() {
45439
46007
  })
45440
46008
  );
45441
46009
  const threads = /* @__PURE__ */ new Map();
46010
+ const suspensionDetector = createSuspensionDetector();
45442
46011
  for (const [directorSessionId, entry] of loadThreadIndex(config2.stateDir, config2.idleThreadTtlMs, Date.now())) {
45443
46012
  threads.set(directorSessionId, {
45444
46013
  threadId: entry.threadId,
@@ -45476,6 +46045,7 @@ async function startDirectorBridge() {
45476
46045
  let changed = false;
45477
46046
  for (const [directorSessionId, thread] of threads.entries()) {
45478
46047
  if (thread.inFlight) continue;
46048
+ if (thread.replay && !shouldGc(thread.replay, now, suspensionDetector.suspendedMs())) continue;
45479
46049
  if (now - thread.lastUsedAt > config2.idleThreadTtlMs) {
45480
46050
  threads.delete(directorSessionId);
45481
46051
  changed = true;
@@ -45491,6 +46061,7 @@ async function startDirectorBridge() {
45491
46061
  acquireCodex: () => codexKeepalive.acquire(),
45492
46062
  releaseCodex: () => codexKeepalive.release(),
45493
46063
  runExclusive: wrappedTurnLock.runExclusive,
46064
+ suspendedMs: () => suspensionDetector.suspendedMs(),
45494
46065
  buildStartParams: (msg, desiredModel) => buildThreadStartParams(msg, config2, desiredModel),
45495
46066
  turnInactivity: () => ({ idleMs: chatTurnIdleMs(), maxMs: chatTurnMaxMs() }),
45496
46067
  checkoutRegistry,
@@ -45653,20 +46224,35 @@ async function startDirectorBridge() {
45653
46224
  );
45654
46225
  enqueueRun(async () => {
45655
46226
  const now = Date.now();
46227
+ const suspendedNow = suspensionDetector.suspendedMs();
46228
+ let retainedBuffers = 0;
46229
+ let retainedBytes = 0;
45656
46230
  for (const [sid, t] of threads) {
45657
- if (t.replay && shouldGc(t.replay, now)) {
46231
+ if (t.replay && shouldGc(t.replay, now, suspendedNow)) {
45658
46232
  const turnId = t.replay.turnId;
45659
46233
  const lastSeq = t.replay.lastSeq;
46234
+ const expiredUnacked = t.replay.ackedAt === null;
45660
46235
  writeTombstone(tombstones, sid, t.replay, now);
45661
46236
  bridgeLogger.logOut({
45662
46237
  spanId: "gc",
45663
46238
  sessionId: sid,
45664
- type: "<tombstone-written>",
46239
+ type: expiredUnacked ? "<result-expired>" : "<tombstone-written>",
45665
46240
  payload: { sessionId: sid, turnId, lastSeq }
45666
46241
  });
45667
46242
  t.replay = null;
46243
+ } else if (t.replay) {
46244
+ retainedBuffers += 1;
46245
+ retainedBytes += t.replay.bytesUsed;
45668
46246
  }
45669
46247
  }
46248
+ if (retainedBuffers > 0) {
46249
+ bridgeLogger.logOut({
46250
+ spanId: "gc",
46251
+ sessionId: null,
46252
+ type: "<buffer-stats>",
46253
+ payload: { retainedBuffers, retainedBytes }
46254
+ });
46255
+ }
45670
46256
  const removed = sweepExpiredTombstones(tombstones, now);
45671
46257
  if (removed > 0) {
45672
46258
  bridgeLogger.logOut({
@@ -45721,6 +46307,7 @@ async function startDirectorBridge() {
45721
46307
  () => server.close(),
45722
46308
  () => dispatchServer.close(),
45723
46309
  () => clearInterval(cleanupTimer),
46310
+ () => suspensionDetector.dispose(),
45724
46311
  () => remoteRelay?.stop(),
45725
46312
  () => tokenRefresher?.stop(),
45726
46313
  () => {
@@ -49624,8 +50211,91 @@ ${dismissedFwd.slice(-20).join("\n")}` : "";
49624
50211
  return { outcome: "CONSENSUS_BLOCKED", rounds: input.rounds, finalArtifact: artifact, history };
49625
50212
  }
49626
50213
 
50214
+ // src/consensus/spec-identity.ts
50215
+ function validateSpecIdentity(markdown, taskId) {
50216
+ const target = taskId.toUpperCase();
50217
+ const fieldToken = frontmatterTaskToken(markdown);
50218
+ if (fieldToken !== null && fieldToken.toUpperCase() !== target) return false;
50219
+ const headingToken = firstHeadingToken(markdown);
50220
+ if (headingToken !== null && headingToken.toUpperCase() !== target) return false;
50221
+ return true;
50222
+ }
50223
+ var TASK_ID_TOKEN = /\b[A-Za-z]{1,12}-\d{1,10}\b/;
50224
+ var ATX_HEADING = /^ {0,3}#{1,6}\s+(.*)$/;
50225
+ var FRONTMATTER_TASK = /^task(?:_id)?:\s*(.+)$/i;
50226
+ function frontmatterTaskToken(markdown) {
50227
+ const lines2 = markdown.split("\n");
50228
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
50229
+ if (firstContent === -1 || lines2[firstContent]?.trim() !== "---") return null;
50230
+ for (let i = firstContent + 1; i < lines2.length; i++) {
50231
+ const trimmed = (lines2[i] ?? "").trim();
50232
+ if (trimmed === "---") break;
50233
+ const m = FRONTMATTER_TASK.exec(trimmed);
50234
+ if (m) return firstTaskIdToken(m[1] ?? "");
50235
+ }
50236
+ return null;
50237
+ }
50238
+ function firstHeadingToken(markdown) {
50239
+ const heading = firstHeading(markdown);
50240
+ return heading === null ? null : firstTaskIdToken(heading);
50241
+ }
50242
+ function firstHeading(markdown) {
50243
+ const lines2 = markdown.split("\n");
50244
+ let i = 0;
50245
+ const firstContent = lines2.findIndex((l) => l.trim() !== "");
50246
+ if (firstContent !== -1 && lines2[firstContent]?.trim() === "---") {
50247
+ i = firstContent + 1;
50248
+ while (i < lines2.length && lines2[i]?.trim() !== "---") i++;
50249
+ i++;
50250
+ }
50251
+ let inFence = false;
50252
+ let inComment = false;
50253
+ for (; i < lines2.length; i++) {
50254
+ const line = lines2[i] ?? "";
50255
+ const trimmed = line.trim();
50256
+ if (inComment) {
50257
+ if (trimmed.includes("-->")) inComment = false;
50258
+ continue;
50259
+ }
50260
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
50261
+ inFence = !inFence;
50262
+ continue;
50263
+ }
50264
+ if (inFence) continue;
50265
+ if (trimmed.startsWith("<!--")) {
50266
+ if (!trimmed.includes("-->")) inComment = true;
50267
+ continue;
50268
+ }
50269
+ const m = ATX_HEADING.exec(line);
50270
+ if (m) return m[1] ?? "";
50271
+ }
50272
+ return null;
50273
+ }
50274
+ function firstTaskIdToken(text) {
50275
+ const m = TASK_ID_TOKEN.exec(text);
50276
+ return m ? m[0] : null;
50277
+ }
50278
+
50279
+ // src/consensus/heads.ts
50280
+ var DEFAULT_DOMAIN = "code";
50281
+ var DOMAINS = ["code", "seo", "marketing", "content"];
50282
+ var HEADS_BY_DOMAIN = {
50283
+ code: ["plan-reviewer", "architect", "security-analyst", "scope-analyst"],
50284
+ seo: ["plan-reviewer", "scope-analyst", "seo-analyst"],
50285
+ marketing: ["plan-reviewer", "scope-analyst", "content-editor"],
50286
+ content: ["plan-reviewer", "scope-analyst", "content-editor"]
50287
+ };
50288
+ var SPEC_HEADS = HEADS_BY_DOMAIN.code;
50289
+ function normalizeDomain(value) {
50290
+ if (typeof value !== "string") return DEFAULT_DOMAIN;
50291
+ const v = value.trim().toLowerCase();
50292
+ return DOMAINS.includes(v) ? v : DEFAULT_DOMAIN;
50293
+ }
50294
+ function resolveHeads(domain2) {
50295
+ return [...HEADS_BY_DOMAIN[normalizeDomain(domain2)]];
50296
+ }
50297
+
49627
50298
  // src/consensus/spec-gate.ts
49628
- var SPEC_HEADS = ["plan-reviewer", "architect", "security-analyst", "scope-analyst"];
49629
50299
  async function runSpecGate(_target, deps, opts) {
49630
50300
  if (!await deps.consensusEnabled()) return { outcome: "SKIPPED" };
49631
50301
  const intent = await deps.readIntent();
@@ -49641,10 +50311,12 @@ async function runSpecGate(_target, deps, opts) {
49641
50311
  ]
49642
50312
  };
49643
50313
  }
50314
+ const baseHeads = resolveHeads(intent.domain);
49644
50315
  const extraHeads = opts?.extraHeads ?? [];
49645
- const heads = [...SPEC_HEADS, ...extraHeads.filter((h) => !SPEC_HEADS.includes(h))];
50316
+ const heads = [...baseHeads, ...extraHeads.filter((h) => !baseHeads.includes(h))];
49646
50317
  const spec = await deps.readSpec();
49647
50318
  let currentHash = spec.contentHash;
50319
+ let currentMarkdown = spec.markdownBody;
49648
50320
  let conflicted = false;
49649
50321
  const result = await runConsensus({
49650
50322
  artifact: spec.markdownBody,
@@ -49656,12 +50328,20 @@ async function runSpecGate(_target, deps, opts) {
49656
50328
  revise: async (artifact, accepted) => {
49657
50329
  const revised = await deps.runReviser(artifact, accepted);
49658
50330
  if (!revised.ok) return artifact;
50331
+ if (intent.taskId !== null && !validateSpecIdentity(revised.text, intent.taskId)) {
50332
+ process.stderr.write(
50333
+ `[spec-gate] discarding drifted revision for ${spec.pageId}: heading does not identify ${intent.taskId}
50334
+ `
50335
+ );
50336
+ return artifact;
50337
+ }
49659
50338
  const cas = await deps.casUpdateSpec(revised.text, currentHash);
49660
50339
  if (!cas.ok) {
49661
50340
  conflicted = true;
49662
50341
  throw new Error("SPEC_CHANGED_DURING_REVIEW");
49663
50342
  }
49664
50343
  currentHash = cas.contentHash;
50344
+ currentMarkdown = revised.text;
49665
50345
  return revised.text;
49666
50346
  }
49667
50347
  // Only OUR conflict throw is swallowed (→ null). Anything else is a real
@@ -49671,6 +50351,22 @@ async function runSpecGate(_target, deps, opts) {
49671
50351
  await deps.writeEvidence({ specPageId: spec.pageId, outcome: "SPEC_CHANGED_DURING_REVIEW" });
49672
50352
  return { outcome: "SPEC_CHANGED_DURING_REVIEW" };
49673
50353
  }
50354
+ if (result.outcome === "CONVERGED" && intent.taskId !== null && !validateSpecIdentity(currentMarkdown, intent.taskId)) {
50355
+ process.stderr.write(
50356
+ `[spec-gate] blocking promotion for ${spec.pageId}: converged spec heading does not identify ${intent.taskId}
50357
+ `
50358
+ );
50359
+ return {
50360
+ outcome: "CONSENSUS_BLOCKED",
50361
+ clarifications: [
50362
+ {
50363
+ 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.`,
50364
+ whyBlocking: "converged spec content belongs to a different task than the one it gates",
50365
+ category: "scope"
50366
+ }
50367
+ ]
50368
+ };
50369
+ }
49674
50370
  await deps.writeEvidence({
49675
50371
  specPageId: spec.pageId,
49676
50372
  outcome: result.outcome,
@@ -49888,6 +50584,40 @@ var import_node_fs11 = __toESM(require("node:fs"), 1);
49888
50584
  var import_node_path13 = __toESM(require("node:path"), 1);
49889
50585
  var import_node_url2 = require("node:url");
49890
50586
 
50587
+ // src/consensus/resolve-gated-task.ts
50588
+ async function resolveGatedTaskId(client, projectSlug, page) {
50589
+ const tasks = await client.listTasks(projectSlug);
50590
+ const linking = tasks.find((t) => t.specWikiPageId === page.id);
50591
+ if (linking) {
50592
+ if (page.taskId !== linking.id) {
50593
+ throw new Error(
50594
+ `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`
50595
+ );
50596
+ }
50597
+ return linking.id;
50598
+ }
50599
+ return page.taskId;
50600
+ }
50601
+
50602
+ // src/consensus/review-identity.ts
50603
+ function domainScopedReviewHash(rawHash, domain2) {
50604
+ const d = normalizeDomain(domain2);
50605
+ return d === "code" ? rawHash : `${rawHash}@${d}`;
50606
+ }
50607
+ async function resolveSpecDomain(client, projectSlug, page) {
50608
+ try {
50609
+ const taskId = await resolveGatedTaskId(client, projectSlug, page);
50610
+ if (taskId !== null) {
50611
+ const task = await client.getTask(projectSlug, taskId);
50612
+ return normalizeDomain(task?.domain);
50613
+ }
50614
+ const project = await client.getProject(projectSlug);
50615
+ return normalizeDomain(project?.defaultDomain);
50616
+ } catch {
50617
+ return "code";
50618
+ }
50619
+ }
50620
+
49891
50621
  // src/consensus/code-gate-git.ts
49892
50622
  var import_node_child_process6 = require("node:child_process");
49893
50623
  var DIFF_MAX_BYTES = 2e5;
@@ -50132,6 +50862,23 @@ function loadPersona(name) {
50132
50862
  personaCache.set(name, content);
50133
50863
  return content;
50134
50864
  }
50865
+ function specHeadIntentBlock(taskId, title, seed) {
50866
+ 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) ---
50867
+ TASK ${taskId}: ${title ?? ""}
50868
+
50869
+ ` : "";
50870
+ const intent = seed ? `--- OPERATOR INTENT (the operator's own words \u2014 judge the spec against THIS) ---
50871
+ ${seed}
50872
+
50873
+ ` : "";
50874
+ return `${identity}${intent}`;
50875
+ }
50876
+ function reviserIdentityInstructions(taskId, title) {
50877
+ 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.
50878
+
50879
+ ` : "";
50880
+ return `${lock}You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.`;
50881
+ }
50135
50882
  function buildSpecGateDeps(args) {
50136
50883
  const { client, projectSlug, specPageId, cwd, codexExecutable, extraHeadCount = 0 } = args;
50137
50884
  const rounds = Number(process.env["MRRLIN_CONSENSUS_ROUNDS"] ?? 3);
@@ -50142,10 +50889,19 @@ function buildSpecGateDeps(args) {
50142
50889
  if (!intentPromise) {
50143
50890
  intentPromise = (async () => {
50144
50891
  const page = await client.getWikiPage(projectSlug, specPageId);
50145
- const taskId = page?.taskId ?? null;
50146
- if (taskId === null) return { taskId: null, seed: null };
50892
+ if (!page) {
50893
+ throw new Error(`spec page ${projectSlug}/${specPageId} not found \u2014 cannot gate`);
50894
+ }
50895
+ const taskId = await resolveGatedTaskId(client, projectSlug, { id: page.id, taskId: page.taskId ?? null });
50896
+ if (taskId === null) {
50897
+ const project = await client.getProject(projectSlug);
50898
+ return { taskId: null, seed: null, title: null, domain: project.defaultDomain };
50899
+ }
50147
50900
  const task = await client.getTask(projectSlug, taskId);
50148
- return { taskId, seed: task?.operatorSeedRequest ?? null };
50901
+ if (!task) {
50902
+ throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
50903
+ }
50904
+ return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "", domain: task.domain };
50149
50905
  })();
50150
50906
  }
50151
50907
  return intentPromise;
@@ -50170,11 +50926,8 @@ function buildSpecGateDeps(args) {
50170
50926
  },
50171
50927
  async runHead(personaName, artifact, dismissed) {
50172
50928
  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
- ` : "";
50929
+ const { taskId, title, seed } = await loadIntent();
50930
+ const intentBlock = specHeadIntentBlock(taskId, title, seed);
50178
50931
  return runCodexExec(
50179
50932
  {
50180
50933
  prompt: `Review this spec against the operator's intent.
@@ -50205,6 +50958,7 @@ ${artifact}`,
50205
50958
  },
50206
50959
  async runReviser(artifact, accepted) {
50207
50960
  const acceptedList = accepted.map((a) => `- (${a.category}) ${a.text}`).join("\n");
50961
+ const { taskId, title } = await loadIntent();
50208
50962
  return runCodexExec(
50209
50963
  {
50210
50964
  prompt: `Accepted issues:
@@ -50212,7 +50966,7 @@ ${acceptedList}
50212
50966
 
50213
50967
  --- SPEC ---
50214
50968
  ${artifact}`,
50215
- developerInstructions: "You revise a spec to address accepted issues. Return ONLY the full revised markdown, no commentary.",
50969
+ developerInstructions: reviserIdentityInstructions(taskId, title),
50216
50970
  cwd,
50217
50971
  timeoutMs,
50218
50972
  sandbox: "read-only",
@@ -50263,9 +51017,10 @@ ${artifact}`,
50263
51017
  }
50264
51018
  const outcome = r["outcome"];
50265
51019
  const rounds2 = r["rounds"];
51020
+ const { domain: domain2 } = await loadIntent();
50266
51021
  try {
50267
51022
  await client.recordSpecConsensusReview(projectSlug, specPageId, {
50268
- contentHash,
51023
+ contentHash: domainScopedReviewHash(contentHash, domain2),
50269
51024
  outcome: String(outcome),
50270
51025
  rounds: typeof rounds2 === "number" ? rounds2 : 0
50271
51026
  });
@@ -50358,11 +51113,51 @@ ${artifact}`,
50358
51113
 
50359
51114
  // src/async-tools/consensus.ts
50360
51115
  var import_node_crypto6 = require("node:crypto");
51116
+ function buildGateQuestionOptions(category) {
51117
+ if (category === "scope") {
51118
+ return [
51119
+ {
51120
+ answer: "Use the narrower scope only. Update the spec to keep the original limited scope and do not add the broader interpretation unless explicitly required.",
51121
+ description: "Do only the originally requested narrower scope and leave the broader bucket out unless explicitly required.",
51122
+ label: "Narrower"
51123
+ },
51124
+ {
51125
+ answer: "Use the broader scope. Update the spec to explicitly include the broader interpretation and any required acceptance criteria.",
51126
+ description: "Treat the broader interpretation as in scope and spell out the extra acceptance criteria it adds.",
51127
+ label: "Broader"
51128
+ }
51129
+ ];
51130
+ }
51131
+ return [
51132
+ {
51133
+ answer: "Use the safer/default interpretation. Update the spec to document that assumption explicitly.",
51134
+ description: "Choose the lower-risk default interpretation and record it explicitly so implementation is unblocked.",
51135
+ label: "Safer default"
51136
+ },
51137
+ {
51138
+ answer: "Use the broader/stronger interpretation. Update the spec to make that interpretation explicit.",
51139
+ description: "Commit to the stronger interpretation instead of a conservative default, and update the spec to match.",
51140
+ label: "Stronger interpretation"
51141
+ }
51142
+ ];
51143
+ }
50361
51144
  async function cachedResult(client, input) {
50362
51145
  const page = await client.getWikiPage(input.projectSlug, input.pageId);
50363
51146
  if (!page) return null;
50364
- const review = await client.getSpecConsensusReview(input.projectSlug, input.pageId, page.contentHash);
51147
+ const domain2 = await resolveSpecDomain(client, input.projectSlug, { id: page.id, taskId: page.taskId ?? null });
51148
+ const review = await client.getSpecConsensusReview(
51149
+ input.projectSlug,
51150
+ input.pageId,
51151
+ domainScopedReviewHash(page.contentHash, domain2)
51152
+ );
50365
51153
  if (review?.outcome === "CONVERGED") {
51154
+ const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
51155
+ id: page.id,
51156
+ taskId: page.taskId ?? null
51157
+ });
51158
+ if (gatedTaskId !== null && !validateSpecIdentity(page.markdownBody, gatedTaskId)) {
51159
+ return null;
51160
+ }
50366
51161
  return { status: "already_converged", payload: { outcome: "CONVERGED", contentHash: page.contentHash } };
50367
51162
  }
50368
51163
  if (review?.outcome === "CONSENSUS_BLOCKED") {
@@ -50404,7 +51199,8 @@ async function run(ctx, input) {
50404
51199
  kind: "question",
50405
51200
  taskId: scopeId,
50406
51201
  body: clarification.question,
50407
- origin: "gate"
51202
+ origin: "gate",
51203
+ questionOptions: buildGateQuestionOptions(clarification.category)
50408
51204
  });
50409
51205
  } catch {
50410
51206
  }
@@ -50419,7 +51215,8 @@ async function run(ctx, input) {
50419
51215
  kind: "question",
50420
51216
  planId: scopeId,
50421
51217
  body: clarification.question,
50422
- origin: "gate"
51218
+ origin: "gate",
51219
+ questionOptions: buildGateQuestionOptions(clarification.category)
50423
51220
  });
50424
51221
  } catch {
50425
51222
  }
@@ -51353,7 +52150,7 @@ var mcpToolDescriptions = {
51353
52150
  [mcpToolNames.resetDirectorThread]: "Clear a task's persisted Director execution thread handle so the next execution run starts a fresh provider thread. Records a director_thread_reset task event.",
51354
52151
  [mcpToolNames.updateWikiPage]: "Update a markdown Wiki page.",
51355
52152
  [mcpToolNames.markSpecReady]: "Mark a wiki page (folder=Specs) as ready (or not) for execution-run gating. The Director sets this true after validating the spec; any edit to the page's markdown auto-resets it to false.",
51356
- [mcpToolNames.runSpecConsensus]: 'Enqueue the consensus review gate over a Specs/{id} page. Returns IMMEDIATELY with one of: already_converged (a CONVERGED review already exists for the current content \u2014 proceed straight to create_execution_run, do NOT call mark_spec_ready, it is already set), already_reviewed (a terminal CONSENSUS_BLOCKED for this content), already_running (a consensus job is in flight), or queued (a new job was launched). The review runs in the background; its result/blocker arrives in the inbox (info on CONVERGED, handoff on failure) and is surfaced into the open chat. Do NOT call again for the same spec and do NOT poll. On queued/already_running, tell the user it was launched and end the turn. Pass extraHeads: ["researcher"] for specs referencing external libraries/APIs, or ["debugger"] for root-cause/investigation specs to add opt-in specialist reviewers on top of the fixed four.',
52153
+ [mcpToolNames.runSpecConsensus]: 'Enqueue the consensus review gate over a Specs/{id} page. Returns IMMEDIATELY with one of: already_converged (a CONVERGED review already exists for the current content \u2014 proceed straight to create_execution_run, do NOT call mark_spec_ready, it is already set), already_reviewed (a terminal CONSENSUS_BLOCKED for this content), already_running (a consensus job is in flight), or queued (a new job was launched). The review runs in the background; its result/blocker arrives in the inbox (info on CONVERGED, handoff on failure) and is surfaced into the open chat. Do NOT call again for the same spec and do NOT poll. On queued/already_running, tell the user it was launched and end the turn. Pass extraHeads: ["researcher"] for specs referencing external libraries/APIs, or ["debugger"] for root-cause/investigation specs to add opt-in specialist reviewers on top of the fixed four. For a NON-code task (domain seo/marketing/content, ADR 0020) the spec is REVIEW-ONLY: do NOT call create_execution_run (it fail-closes with non_code_execution_unsupported until Phase 2) \u2014 report that the spec was reviewed and end the turn.',
51357
52154
  [mcpToolNames.runCodeReviewerGate]: `Review the current branch's code diff (vs baseRef) with a Code Reviewer gate \u2014 independent reviewers iterate review\u2192fix\u2192re-check (checkpoint, scope-limited reviser edits, typecheck/build fast-gate, rollback) until the diff converges or is blocked. Run before opening a PR. Pass extraHeads: ["security-analyst"] for sensitive diffs (auth, secrets, input handling, new endpoints).`,
51358
52155
  [mcpToolNames.appendExecutionArtifact]: "Append a durable artifact (plan, checkpoint, command output, test result, diff summary, self-review, error, deploy attempt, or handoff judgement) to an execution run.",
51359
52156
  [mcpToolNames.recordProducedUrl]: 'Call this after publishing each web page produced by the task, so the UI link opens the page itself instead of the deployed site root. Pass the page\'s full https URL (for external pages, e.g. "https://dev.to/<slug>") OR a path starting with "/" for the deploy environment (e.g. "/blog/<slug>") \u2014 paths are resolved against the run\'s deployed env URL at render time. Optional `label` (\u226440 chars) is shown next to the link; omit and the UI defaults to the page title. Call once per page; multiple calls dedupe by resolved URL in the UI.',
@@ -51810,7 +52607,12 @@ function createMrrlinTools(options) {
51810
52607
  if (!page || page.folder !== "Specs") {
51811
52608
  return await c.markSpecReady({ projectSlug, pageId, ready });
51812
52609
  }
51813
- const review = await c.getSpecConsensusReview(projectSlug, pageId, page.contentHash);
52610
+ const reviewDomain = await resolveSpecDomain(c, projectSlug, { id: page.id, taskId: page.taskId ?? null });
52611
+ const review = await c.getSpecConsensusReview(
52612
+ projectSlug,
52613
+ pageId,
52614
+ domainScopedReviewHash(page.contentHash, reviewDomain)
52615
+ );
51814
52616
  if (review && review.outcome === "CONVERGED") {
51815
52617
  try {
51816
52618
  const promotion = await c.promoteSpecReadyCas(projectSlug, pageId, {
@@ -52585,6 +53387,13 @@ Usage:
52585
53387
  non-loopback URL (see \`mrrlin-mcp serve\` above).
52586
53388
  URL and MRRLIN_STAGING are mutually exclusive at
52587
53389
  bake time \u2014 URL wins, mirrors serve's resolver.
53390
+ Re-running BARE (no env vars) over a block you
53391
+ already pinned to staging/a remote target is safe:
53392
+ it keeps your env verbatim (reported as
53393
+ 'env-preserved') instead of conflicting, so the
53394
+ same setup command doubles as an update command.
53395
+ To CHANGE or REMOVE the baked target, re-run with
53396
+ --force (or the new env vars).
52588
53397
  --force replaces an existing conflicting block.
52589
53398
  --force-prompts overwrites prompt files that
52590
53399
  already exist with different content. WITHOUT it,
@@ -52631,8 +53440,18 @@ Usage:
52631
53440
  - MRRLIN_DIRECTOR_BRIDGE_CODEX_IDLE_SECONDS (default 300;
52632
53441
  how long the warm codex app-server is kept alive
52633
53442
  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)
53443
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_IDLE_TIMEOUT_SECONDS (default 600;
53444
+ an execution turn is cut only after this long with NO codex
53445
+ signal at all \u2014 long-but-active turns keep running. A stall
53446
+ earns one automatic retry)
53447
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_RUN_MAX_SECONDS (default 3600;
53448
+ overall wall-clock budget for the active-turn portion of one
53449
+ claim attempt, measured from claim. Exceeding it hands off with
53450
+ NO retry. Setup/post-turn hangs are bounded by the stale reaper,
53451
+ not by this budget)
53452
+ - MRRLIN_DIRECTOR_BRIDGE_EXEC_TURN_TIMEOUT_SECONDS (DEPRECATED;
53453
+ unset by default. When set, an extra per-turn hard cap: the
53454
+ effective turn deadline becomes min(this, remaining run budget))
52636
53455
  - MRRLIN_DIRECTOR_BRIDGE_CONTEXT_MAX_ARTIFACTS (default 50;
52637
53456
  most-recent artifacts kept in the execution context bundle)
52638
53457
 
@@ -52747,6 +53566,10 @@ async function main() {
52747
53566
  });
52748
53567
  process.stderr.write(`[mrrlin-mcp install-codex] ${result.action} ${result.configPath}
52749
53568
  `);
53569
+ for (const preserved of result.preservedEnvBlocks) {
53570
+ process.stderr.write(`${renderEnvPreservedNotice(preserved.blockId, preserved.envKeys)}
53571
+ `);
53572
+ }
52750
53573
  for (const p of result.prompts) {
52751
53574
  process.stderr.write(`[mrrlin-mcp install-codex] prompt ${p.name}: ${p.action} ${p.promptPath}
52752
53575
  `);