@mrrlin-dev/mcp 0.3.15 → 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
@@ -20985,10 +20985,15 @@ var import_node_child_process4 = require("node:child_process");
20985
20985
  var import_node_crypto5 = __toESM(require("node:crypto"), 1);
20986
20986
 
20987
20987
  // src/turn-replay-buffer.ts
20988
- var DEFAULT_GRACE_MS = 3e4;
20989
20988
  var DEFAULT_MAX_EVENTS = 2e3;
20990
20989
  var DEFAULT_MAX_BYTES = 4e6;
20991
- 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
+ }
20992
20997
  function createReplayBuffer(opts) {
20993
20998
  return {
20994
20999
  turnId: opts.turnId,
@@ -21000,14 +21005,15 @@ function createReplayBuffer(opts) {
21000
21005
  terminalDelivered: false,
21001
21006
  replaying: false,
21002
21007
  outboundQueue: [],
21003
- graceTimer: null,
21004
21008
  finishedAt: null,
21009
+ finishedSuspendedMs: null,
21010
+ ackedAt: null,
21005
21011
  bytesUsed: 0,
21006
- graceMs: opts.graceMs ?? DEFAULT_GRACE_MS,
21007
21012
  maxEvents: opts.maxEvents ?? DEFAULT_MAX_EVENTS,
21008
21013
  maxBytes: opts.maxBytes ?? DEFAULT_MAX_BYTES,
21009
- postCompletionTtlMs: opts.postCompletionTtlMs ?? DEFAULT_POST_COMPLETION_TTL_MS,
21010
- triggerGrace: null
21014
+ ackLingerMs: opts.ackLingerMs ?? DEFAULT_ACK_LINGER_MS,
21015
+ retentionMs: opts.retentionMs ?? resultRetentionMs(),
21016
+ triggerDetach: null
21011
21017
  };
21012
21018
  }
21013
21019
  function recordOutgoing(buf, msg) {
@@ -21016,9 +21022,10 @@ function recordOutgoing(buf, msg) {
21016
21022
  buf.events.push(entry);
21017
21023
  buf.bytesUsed += approxByteSize(msg);
21018
21024
  while ((buf.events.length > buf.maxEvents || buf.bytesUsed > buf.maxBytes) && buf.events.length > 0) {
21019
- const dropped = buf.events.shift();
21020
- if (!dropped) break;
21021
- 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);
21022
21029
  }
21023
21030
  return entry;
21024
21031
  }
@@ -21027,19 +21034,7 @@ function eventsAfter(buf, lastSeq) {
21027
21034
  if (first && lastSeq < first.seq - 1) return null;
21028
21035
  return buf.events.filter((e) => e.seq > lastSeq);
21029
21036
  }
21030
- function enterGrace(buf, onExpire) {
21031
- if (buf.graceTimer !== null) return;
21032
- buf.state = "grace";
21033
- buf.graceTimer = setTimeout(onExpire, buf.graceMs);
21034
- }
21035
- function cancelGrace(buf) {
21036
- if (buf.graceTimer !== null) {
21037
- clearTimeout(buf.graceTimer);
21038
- buf.graceTimer = null;
21039
- }
21040
- if (buf.state === "grace") buf.state = "live";
21041
- }
21042
- function recordInterrupted(buf) {
21037
+ function recordInterrupted(buf, now = Date.now(), suspendedMsNow = 0) {
21043
21038
  if (buf.terminalDelivered) return null;
21044
21039
  const msg = {
21045
21040
  type: "event",
@@ -21047,16 +21042,31 @@ function recordInterrupted(buf) {
21047
21042
  };
21048
21043
  const entry = recordOutgoing(buf, msg);
21049
21044
  buf.terminalDelivered = true;
21045
+ if (buf.finishedAt === null) {
21046
+ buf.finishedAt = now;
21047
+ buf.finishedSuspendedMs = suspendedMsNow;
21048
+ }
21050
21049
  return entry;
21051
21050
  }
21052
- function markCompleted(buf, now) {
21051
+ function markCompleted(buf, now, suspendedMsNow = 0) {
21053
21052
  buf.state = "completed";
21054
21053
  buf.finishedAt = now;
21054
+ buf.finishedSuspendedMs = suspendedMsNow;
21055
21055
  }
21056
- function shouldGc(buf, now) {
21057
- if (buf.state !== "completed") return false;
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;
21062
+ }
21063
+ function shouldGc(buf, now, suspendedMsNow = 0) {
21064
+ if (buf.state !== "completed" && buf.state !== "interrupted") return false;
21058
21065
  if (buf.finishedAt === null) return false;
21059
- 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;
21060
21070
  }
21061
21071
  function incrementBindEpoch(buf) {
21062
21072
  return ++buf.bindEpoch;
@@ -21077,6 +21087,41 @@ function approxByteSize(msg) {
21077
21087
  }
21078
21088
  }
21079
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
+
21080
21125
  // src/replay-state-machine.ts
21081
21126
  function transition(state, event) {
21082
21127
  if (event.kind === "resume" && !event.epochMatch) return { next: "invalid" };
@@ -21084,7 +21129,12 @@ function transition(state, event) {
21084
21129
  case "live":
21085
21130
  switch (event.kind) {
21086
21131
  case "socket_close":
21087
- 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
+ };
21088
21138
  case "codex_terminal":
21089
21139
  return { next: "completed", sideEffects: ["mark_finished"] };
21090
21140
  case "stop":
@@ -21095,35 +21145,22 @@ function transition(state, event) {
21095
21145
  default:
21096
21146
  return { next: "invalid" };
21097
21147
  }
21098
- case "grace":
21148
+ case "detached":
21099
21149
  switch (event.kind) {
21150
+ case "socket_close":
21151
+ return { next: "detached", sideEffects: [] };
21152
+ // idempotent
21100
21153
  case "resume":
21101
21154
  return {
21102
21155
  next: "live",
21103
- sideEffects: [
21104
- "cancel_grace_timer",
21105
- "rebind_sink_increment_epoch",
21106
- "replay_drain"
21107
- ]
21108
- };
21109
- case "grace_expire":
21110
- return {
21111
- next: "interrupted",
21112
- sideEffects: ["record_interrupted", "fire_turn_interrupt"]
21156
+ sideEffects: ["rebind_sink_increment_epoch", "replay_drain"]
21113
21157
  };
21158
+ case "codex_terminal":
21159
+ return { next: "completed", sideEffects: ["mark_finished"] };
21114
21160
  case "stop":
21115
21161
  return {
21116
21162
  next: "interrupted",
21117
- sideEffects: [
21118
- "cancel_grace_timer",
21119
- "record_interrupted",
21120
- "fire_turn_interrupt"
21121
- ]
21122
- };
21123
- case "codex_terminal":
21124
- return {
21125
- next: "completed",
21126
- sideEffects: ["cancel_grace_timer", "mark_finished"]
21163
+ sideEffects: ["record_interrupted", "fire_turn_interrupt"]
21127
21164
  };
21128
21165
  default:
21129
21166
  return { next: "invalid" };
@@ -36481,10 +36518,12 @@ var mrrlinTaskStatusSchema = external_exports.enum([
36481
36518
  "closed_done",
36482
36519
  "closed_no_result"
36483
36520
  ]);
36521
+ var mrrlinTerminalTaskStatuses = ["closed_done", "closed_no_result"];
36484
36522
  var mrrlinTaskTypeSchema = external_exports.enum(["task", "bug"]);
36485
36523
  var mrrlinTaskImpactSchema = external_exports.enum(["low", "medium", "high"]);
36486
36524
  var mrrlinAutonomyLevelSchema = external_exports.enum(["auto", "minimal_human", "human_only"]);
36487
36525
  var mrrlinAutoDeploySchema = external_exports.enum(["off", "dev", "prod"]);
36526
+ var mrrlinTaskDomainSchema = external_exports.enum(["code", "seo", "marketing", "content"]);
36488
36527
  var mrrlinSpecApprovedSchema = external_exports.enum(["pending", "approved", "skipped", "skipped_legacy"]);
36489
36528
  var mrrlinTaskSourceSchema = external_exports.enum(["fixture-import", "api"]);
36490
36529
  var mrrlinPlanStatusSchema = external_exports.enum(["active", "paused", "done", "archived"]);
@@ -36516,6 +36555,27 @@ function normalizeTaskTags(tags) {
36516
36555
  }
36517
36556
  return [...normalized].sort();
36518
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
+ }
36519
36579
  var mrrlinTaskTagSchema = external_exports.string().min(1).max(40).transform(normalizeTaskTagInput).pipe(external_exports.string().min(1).max(40));
36520
36580
  var mrrlinTaskTagsSchema = external_exports.array(mrrlinTaskTagSchema).default([]).transform(normalizeTaskTags);
36521
36581
  var mrrlinExecutionRunStatusSchema = external_exports.enum([
@@ -36569,6 +36629,10 @@ var mrrlinTaskAssigneeSchema = external_exports.union([external_exports.string()
36569
36629
  var mrrlinTaskSchema = external_exports.object({
36570
36630
  assignee: mrrlinTaskAssigneeSchema,
36571
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(),
36572
36636
  autonomyLevel: mrrlinAutonomyLevelSchema,
36573
36637
  autonomyReason: external_exports.string().min(1).max(240),
36574
36638
  blockedBy: external_exports.array(mrrlinTaskIdSchema),
@@ -36686,7 +36750,10 @@ var mrrlinProjectSummarySchema = external_exports.object({
36686
36750
  emoji: mrrlinProjectEmojiSchema,
36687
36751
  github: mrrlinGithubBindingSchema,
36688
36752
  onboardingKickoffState: external_exports.enum(["pending", "sent"]).nullable().optional(),
36689
- 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()
36690
36757
  });
36691
36758
  var mrrlinProjectUpsertSchema = mrrlinProjectSummarySchema.omit({
36692
36759
  onboardingKickoffState: true,
@@ -36735,6 +36802,9 @@ var mrrlinProjectSnapshotSchema = external_exports.object({
36735
36802
  });
36736
36803
  var mrrlinTaskCreateSchema = external_exports.object({
36737
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(),
36738
36808
  autonomyLevel: mrrlinAutonomyLevelSchema.default("minimal_human"),
36739
36809
  autonomyReason: external_exports.string().min(1).max(240).default("Needs human review before automation."),
36740
36810
  category: taxonomyCategorySchema,
@@ -36758,6 +36828,7 @@ var mrrlinTaskUpdateSchema = external_exports.object({
36758
36828
  actor: external_exports.string().min(1).optional(),
36759
36829
  assignee: mrrlinTaskAssigneeSchema.optional(),
36760
36830
  autoDeploy: mrrlinAutoDeploySchema.optional(),
36831
+ domain: mrrlinTaskDomainSchema.optional(),
36761
36832
  autonomyLevel: mrrlinAutonomyLevelSchema.optional(),
36762
36833
  autonomyReason: external_exports.string().min(1).max(240).optional(),
36763
36834
  category: taxonomyCategorySchema.optional(),
@@ -37391,6 +37462,43 @@ var mrrlinInboxItemActionPayloadSchema = external_exports.object({
37391
37462
  // (worktree + push/PR path). Scheduler proposals set it; legacy payloads omit it.
37392
37463
  codeExecution: external_exports.boolean().optional()
37393
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
+ });
37394
37502
  var mrrlinInboxItemSchema = external_exports.object({
37395
37503
  actionPayload: mrrlinInboxItemActionPayloadSchema.nullable(),
37396
37504
  answer: external_exports.string().nullable(),
@@ -37406,9 +37514,18 @@ var mrrlinInboxItemSchema = external_exports.object({
37406
37514
  origin: external_exports.string().nullable().optional(),
37407
37515
  planId: mrrlinPlanIdSchema.nullable().default(null),
37408
37516
  projectSlug: mrrlinProjectSlugSchema,
37517
+ questionOptions: mrrlinInboxQuestionOptionsSchema.nullable().default(null),
37409
37518
  readAt: external_exports.string().datetime().nullable(),
37410
37519
  status: mrrlinInboxItemStatusSchema,
37411
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
+ }
37412
37529
  });
37413
37530
  var mrrlinInboxItemCreateSchema = external_exports.object({
37414
37531
  actionPayload: mrrlinInboxItemActionPayloadSchema.nullable().optional(),
@@ -37418,7 +37535,16 @@ var mrrlinInboxItemCreateSchema = external_exports.object({
37418
37535
  kind: mrrlinInboxItemKindSchema,
37419
37536
  origin: external_exports.string().min(1).optional(),
37420
37537
  planId: mrrlinPlanIdSchema.nullable().optional(),
37538
+ questionOptions: mrrlinInboxQuestionOptionsSchema.nullable().optional(),
37421
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
+ }
37422
37548
  });
37423
37549
  var mrrlinInboxItemDecideSchema = external_exports.object({
37424
37550
  answer: external_exports.string().min(1).optional(),
@@ -38608,7 +38734,7 @@ Do NOT derail the current task - if the operator has an in-flight question, answ
38608
38734
  }
38609
38735
 
38610
38736
  // src/_generated/version.ts
38611
- var PKG_VERSION = "0.3.15";
38737
+ var PKG_VERSION = "0.3.20";
38612
38738
 
38613
38739
  // src/api-base-url.ts
38614
38740
  var DEFAULT_API_BASE_URL = "http://127.0.0.1:8787";
@@ -39370,6 +39496,31 @@ async function driveRun(deps, projectSlug, runId) {
39370
39496
  return legacy != null ? Math.min(legacy, remaining) : remaining;
39371
39497
  };
39372
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
+ }
39373
39524
  const compactEnabled = (process.env.MRRLIN_MCP_COMPACT_DISABLE ?? "").trim() !== "1";
39374
39525
  const context = await gatherContext(deps.client, projectSlug, task, runId);
39375
39526
  const bundle = renderContextBundle(
@@ -39391,7 +39542,11 @@ async function driveRun(deps, projectSlug, runId) {
39391
39542
  };
39392
39543
  if (claimed.codeExecution) {
39393
39544
  if (!checkout) {
39394
- 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.`;
39395
39550
  await failRun(deps, projectSlug, task, runId, leaseId, reason);
39396
39551
  return;
39397
39552
  }
@@ -40037,9 +40192,15 @@ function saveThreadIndex(stateDir, entries) {
40037
40192
  function createInactivityWatchdog(opts) {
40038
40193
  const setT = opts.setTimeoutFn ?? setTimeout;
40039
40194
  const clearT = opts.clearTimeoutFn ?? clearTimeout;
40195
+ const now = opts.now ?? Date.now;
40196
+ const suspended = opts.suspendedMs ?? (() => 0);
40040
40197
  let idleTimer = null;
40041
40198
  let maxTimer = null;
40042
40199
  let done = false;
40200
+ const startAt = now();
40201
+ const suspendedAtStart = suspended();
40202
+ let lastPokeAt = startAt;
40203
+ let suspendedAtLastPoke = suspendedAtStart;
40043
40204
  const expire = (reason) => {
40044
40205
  if (done) return;
40045
40206
  done = true;
@@ -40047,16 +40208,41 @@ function createInactivityWatchdog(opts) {
40047
40208
  if (maxTimer) clearT(maxTimer);
40048
40209
  opts.onExpire(reason);
40049
40210
  };
40050
- idleTimer = setT(() => expire("idle"), opts.idleMs);
40051
- idleTimer.unref?.();
40052
- maxTimer = setT(() => expire("max"), opts.maxMs);
40053
- 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);
40054
40239
  return {
40055
40240
  poke: () => {
40056
40241
  if (done) return;
40057
40242
  if (idleTimer) clearT(idleTimer);
40058
- idleTimer = setT(() => expire("idle"), opts.idleMs);
40059
- idleTimer.unref?.();
40243
+ lastPokeAt = now();
40244
+ suspendedAtLastPoke = suspended();
40245
+ armIdle(opts.idleMs);
40060
40246
  },
40061
40247
  cancel: () => {
40062
40248
  done = true;
@@ -44232,6 +44418,7 @@ function createBridgeLogger(stateDir, env = process.env) {
44232
44418
  }
44233
44419
 
44234
44420
  // src/director-bridge.ts
44421
+ var JSON_GPT_MODEL_REF_RE = /"(?:slug|model)"\s*:\s*"(gpt-[a-z0-9.-]+)"/gi;
44235
44422
  var DEFAULT_DIRECTOR_BRIDGE_ALLOWED_ORIGINS = [
44236
44423
  "http://localhost:*",
44237
44424
  "http://127.0.0.1:*",
@@ -44252,6 +44439,139 @@ function normalizeModel(value) {
44252
44439
  const trimmed = value.trim();
44253
44440
  return trimmed ? trimmed : null;
44254
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
+ }
44255
44575
  function neutralCheckoutCwd(stateDir) {
44256
44576
  const dir = import_node_path11.default.join(stateDir, "no-checkout");
44257
44577
  try {
@@ -44288,7 +44608,7 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44288
44608
  " bullets (`- AND ...` as needed). Scenarios make the spec verifiable.",
44289
44609
  "- Keep behavior contracts in the spec; implementation details belong in `## Implementation Plan`.",
44290
44610
  "- Apply this format ONLY to Specs/{id} pages; do not retro-fit it onto other wiki page types.",
44291
- "- 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.",
44292
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.',
44293
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.",
44294
44614
  "",
@@ -44357,8 +44677,12 @@ function buildThreadStartParams(msg, config2, desiredModel) {
44357
44677
  " assumption on the spec page.",
44358
44678
  "- For any such task-affecting question, ALSO bank it durably before your final reply:",
44359
44679
  ' `create_inbox_item` with `kind="question"`, `taskId` set, `directorSessionId` from the',
44360
- " Context JSON, and `body` = the exact question. (The server dedups identical open",
44361
- " 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.)",
44362
44686
  "- When the operator answers in chat, write the answer into the Specs/{taskId}",
44363
44687
  ' Clarifications section, then `decide_inbox_item` the question with `verdict="acknowledged"`',
44364
44688
  " and `answer` = the resolved answer.",
@@ -44689,6 +45013,24 @@ function attachBridgeWss(server, opts) {
44689
45013
  return;
44690
45014
  }
44691
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));
44692
45034
  const send = (msg) => {
44693
45035
  if (socket.readyState !== socket.OPEN) return;
44694
45036
  logger?.logOut({
@@ -44732,6 +45074,20 @@ function attachBridgeWss(server, opts) {
44732
45074
  }
44733
45075
  var TurnInterruptedError = class extends Error {
44734
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
+ }
44735
45091
  function attachSeqMetadata(msg, seq, turnId) {
44736
45092
  if (msg.type === "event" || msg.type === "rateLimits" || msg.type === "error") {
44737
45093
  return { ...msg, seq, turnId };
@@ -44790,11 +45146,11 @@ function createBridgeMessageHandler(deps) {
44790
45146
  logResumeRejected(req.directorSessionId, "superseded");
44791
45147
  return;
44792
45148
  }
44793
- const tail2 = eventsAfter(buf, req.lastSeq);
45149
+ let tail2 = eventsAfter(buf, req.lastSeq);
45150
+ let truncated = false;
44794
45151
  if (tail2 === null) {
44795
- sendMsg({ type: "resume", status: "no-buffer", reason: "evicted" });
44796
- logResumeRejected(req.directorSessionId, "evicted");
44797
- return;
45152
+ truncated = true;
45153
+ tail2 = [...buf.events];
44798
45154
  }
44799
45155
  const result = transition(buf.state, { kind: "resume", epochMatch: true });
44800
45156
  if (result.next === "invalid") {
@@ -44806,7 +45162,14 @@ function createBridgeMessageHandler(deps) {
44806
45162
  buf.replaying = true;
44807
45163
  deps.bindSessionSink?.(req.directorSessionId, send);
44808
45164
  boundSessions.add(req.directorSessionId);
44809
- 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
+ }
44810
45173
  const willStreamLive = result.next === "live";
44811
45174
  sendMsg({
44812
45175
  type: "resume",
@@ -44819,6 +45182,14 @@ function createBridgeMessageHandler(deps) {
44819
45182
  eventCount: tail2.length,
44820
45183
  willStreamLive
44821
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
+ }
44822
45193
  for (const { msg: bufferedMsg } of tail2) sendMsg(bufferedMsg);
44823
45194
  for (const m of buf.outboundQueue) sendMsg(m);
44824
45195
  buf.outboundQueue.length = 0;
@@ -44897,26 +45268,36 @@ function createBridgeMessageHandler(deps) {
44897
45268
  let models = null;
44898
45269
  let rateLimits = null;
44899
45270
  let bootstrapError;
45271
+ const fallbackModels = readCodexFallbackModels();
44900
45272
  try {
44901
45273
  const codexClient2 = await deps.acquireCodex();
44902
45274
  try {
44903
45275
  try {
44904
- const res = await codexClient2.model.list({});
45276
+ const res = await codexClient2.model.list({ includeHidden: true });
44905
45277
  const raw = res.data ?? res.models ?? [];
44906
- models = raw.filter((m) => m.hidden !== true).map((m) => ({
44907
- id: m.id,
44908
- model: m.model ?? m.id,
44909
- displayName: m.displayName ?? m.model ?? m.id,
44910
- description: m.description ?? "",
44911
- supportedReasoningEfforts: (m.supportedReasoningEfforts ?? []).map((o) => ({
44912
- reasoningEffort: o.reasoningEffort,
44913
- description: o.description ?? ""
44914
- })),
44915
- defaultReasoningEffort: m.defaultReasoningEffort ?? null,
44916
- isDefault: m.isDefault === true
44917
- }));
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()];
44918
45298
  } catch (error51) {
44919
45299
  bootstrapError = error51 instanceof Error ? error51.message : String(error51);
45300
+ models = fallbackModels.length > 0 ? fallbackModels : null;
44920
45301
  }
44921
45302
  try {
44922
45303
  const res = await codexClient2.account.readRateLimits();
@@ -44929,6 +45310,7 @@ function createBridgeMessageHandler(deps) {
44929
45310
  }
44930
45311
  } catch (error51) {
44931
45312
  bootstrapError = error51 instanceof Error ? error51.message : String(error51);
45313
+ models = fallbackModels.length > 0 ? fallbackModels : null;
44932
45314
  }
44933
45315
  sendForSpan({ type: "bootstrap", models, rateLimits, ...deps.bridgeInstanceId ? { bridgeInstanceId: deps.bridgeInstanceId } : {}, ...bootstrapError ? { error: bootstrapError } : {} });
44934
45316
  return;
@@ -44937,6 +45319,20 @@ function createBridgeMessageHandler(deps) {
44937
45319
  handleResumeRequest(msg, sendForSpan);
44938
45320
  return;
44939
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
+ }
44940
45336
  if (msg.type === "interrupt") {
44941
45337
  const directorSessionId2 = (msg.directorSessionId ?? "").trim();
44942
45338
  const existing = directorSessionId2 ? deps.threads.get(directorSessionId2) : void 0;
@@ -44945,12 +45341,11 @@ function createBridgeMessageHandler(deps) {
44945
45341
  return;
44946
45342
  }
44947
45343
  const interruptBuf = existing.replay;
44948
- if (interruptBuf && interruptBuf.state === "grace") {
44949
- const stopResult = transition("grace", { kind: "stop" });
45344
+ if (interruptBuf && interruptBuf.state === "detached") {
45345
+ const stopResult = transition("detached", { kind: "stop" });
44950
45346
  if (stopResult.next !== "invalid") {
44951
45347
  interruptBuf.state = stopResult.next;
44952
- cancelGrace(interruptBuf);
44953
- recordInterrupted(interruptBuf);
45348
+ recordInterrupted(interruptBuf, Date.now(), deps.suspendedMs?.() ?? 0);
44954
45349
  }
44955
45350
  }
44956
45351
  const codexClient2 = await deps.acquireCodex();
@@ -45012,6 +45407,36 @@ function createBridgeMessageHandler(deps) {
45012
45407
  }
45013
45408
  const codexClient = await deps.acquireCodex();
45014
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
+ }
45015
45440
  const desiredModel = normalizeModel(msg.model);
45016
45441
  const desiredEffort = normalizeReasoningEffort(msg.reasoningEffort);
45017
45442
  try {
@@ -45069,7 +45494,13 @@ function createBridgeMessageHandler(deps) {
45069
45494
  return;
45070
45495
  }
45071
45496
  if (thread.inFlight) {
45072
- 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
+ });
45073
45504
  deps.releaseCodex();
45074
45505
  return;
45075
45506
  }
@@ -45077,7 +45508,8 @@ function createBridgeMessageHandler(deps) {
45077
45508
  thread.replay = createReplayBuffer({
45078
45509
  turnId: (0, import_node_crypto5.randomUUID)(),
45079
45510
  spanId,
45080
- ...deps.turnGraceMs !== void 0 ? { graceMs: deps.turnGraceMs } : {}
45511
+ ...deps.retentionMs !== void 0 ? { retentionMs: deps.retentionMs } : {},
45512
+ ...deps.ackLingerMs !== void 0 ? { ackLingerMs: deps.ackLingerMs } : {}
45081
45513
  });
45082
45514
  thread.lastUsedAt = Date.now();
45083
45515
  const threadId = thread.threadId;
@@ -45168,6 +45600,7 @@ function createBridgeMessageHandler(deps) {
45168
45600
  const watchdog = createInactivityWatchdog({
45169
45601
  idleMs,
45170
45602
  maxMs,
45603
+ ...deps.suspendedMs ? { suspendedMs: deps.suspendedMs } : {},
45171
45604
  onExpire: (reason) => {
45172
45605
  rejectWatchdog?.(
45173
45606
  new Error(
@@ -45176,48 +45609,36 @@ function createBridgeMessageHandler(deps) {
45176
45609
  );
45177
45610
  void withTimeout(codexClient.turn.interrupt({ threadId }), DEFAULT_TURN_INTERRUPT_TIMEOUT_MS, "interrupt timed out").catch(
45178
45611
  () => {
45179
- deps.threads.delete(directorSessionId);
45180
- deps.persistThreads();
45612
+ if (deps.threads.get(directorSessionId) === thread) {
45613
+ deps.threads.delete(directorSessionId);
45614
+ deps.persistThreads();
45615
+ }
45181
45616
  }
45182
45617
  );
45183
45618
  }
45184
45619
  });
45620
+ const turnBuf = thread.replay;
45185
45621
  const onSocketClose = () => {
45186
45622
  const buf = thread.replay;
45187
- if (!buf || buf.state !== "live") return;
45188
- const r = transition("live", { kind: "socket_close" });
45189
- 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;
45190
45626
  buf.state = r.next;
45191
45627
  logCtx?.logger.logOut({
45192
45628
  spanId,
45193
45629
  sessionId: directorSessionId,
45194
- type: "<resume-grace-started>",
45195
- payload: { sessionId: directorSessionId, turnId: buf.turnId, graceMs: buf.graceMs }
45196
- });
45197
- enterGrace(buf, () => {
45198
- const expired = transition("grace", { kind: "grace_expire" });
45199
- if (expired.next === "invalid") return;
45200
- buf.state = expired.next;
45201
- recordInterrupted(buf);
45202
- logCtx?.logger.logOut({
45203
- spanId,
45204
- sessionId: directorSessionId,
45205
- type: "<grace-expired>",
45206
- payload: { sessionId: directorSessionId, turnId: buf.turnId, lastSeq: buf.lastSeq }
45207
- });
45208
- rejectWatchdog?.(new TurnInterruptedError());
45209
- void withTimeout(
45210
- codexClient.turn.interrupt({ threadId }),
45211
- DEFAULT_TURN_INTERRUPT_TIMEOUT_MS,
45212
- "interrupt timed out"
45213
- ).catch(() => {
45214
- deps.threads.delete(directorSessionId);
45215
- deps.persistThreads();
45216
- });
45630
+ type: "<buffer-detached>",
45631
+ payload: { sessionId: directorSessionId, turnId: buf.turnId, lastSeq: buf.lastSeq }
45217
45632
  });
45218
45633
  };
45219
- socket.once("close", onSocketClose);
45220
- 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;
45221
45642
  pokeActivity = () => watchdog.poke();
45222
45643
  const runSequence = (async () => {
45223
45644
  const turnInput = [];
@@ -45266,10 +45687,20 @@ ${effectiveText}` : effectiveText;
45266
45687
  sendInTurn({ type: "event", directorSessionId, event });
45267
45688
  }
45268
45689
  } finally {
45269
- if (thread.replay && thread.replay.state === "live") {
45270
- 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
+ });
45271
45703
  }
45272
- socket.off("close", onSocketClose);
45273
45704
  watchdog.cancel();
45274
45705
  unsub();
45275
45706
  thread.cancel = null;
@@ -45576,6 +46007,7 @@ async function startDirectorBridge() {
45576
46007
  })
45577
46008
  );
45578
46009
  const threads = /* @__PURE__ */ new Map();
46010
+ const suspensionDetector = createSuspensionDetector();
45579
46011
  for (const [directorSessionId, entry] of loadThreadIndex(config2.stateDir, config2.idleThreadTtlMs, Date.now())) {
45580
46012
  threads.set(directorSessionId, {
45581
46013
  threadId: entry.threadId,
@@ -45613,6 +46045,7 @@ async function startDirectorBridge() {
45613
46045
  let changed = false;
45614
46046
  for (const [directorSessionId, thread] of threads.entries()) {
45615
46047
  if (thread.inFlight) continue;
46048
+ if (thread.replay && !shouldGc(thread.replay, now, suspensionDetector.suspendedMs())) continue;
45616
46049
  if (now - thread.lastUsedAt > config2.idleThreadTtlMs) {
45617
46050
  threads.delete(directorSessionId);
45618
46051
  changed = true;
@@ -45628,6 +46061,7 @@ async function startDirectorBridge() {
45628
46061
  acquireCodex: () => codexKeepalive.acquire(),
45629
46062
  releaseCodex: () => codexKeepalive.release(),
45630
46063
  runExclusive: wrappedTurnLock.runExclusive,
46064
+ suspendedMs: () => suspensionDetector.suspendedMs(),
45631
46065
  buildStartParams: (msg, desiredModel) => buildThreadStartParams(msg, config2, desiredModel),
45632
46066
  turnInactivity: () => ({ idleMs: chatTurnIdleMs(), maxMs: chatTurnMaxMs() }),
45633
46067
  checkoutRegistry,
@@ -45790,20 +46224,35 @@ async function startDirectorBridge() {
45790
46224
  );
45791
46225
  enqueueRun(async () => {
45792
46226
  const now = Date.now();
46227
+ const suspendedNow = suspensionDetector.suspendedMs();
46228
+ let retainedBuffers = 0;
46229
+ let retainedBytes = 0;
45793
46230
  for (const [sid, t] of threads) {
45794
- if (t.replay && shouldGc(t.replay, now)) {
46231
+ if (t.replay && shouldGc(t.replay, now, suspendedNow)) {
45795
46232
  const turnId = t.replay.turnId;
45796
46233
  const lastSeq = t.replay.lastSeq;
46234
+ const expiredUnacked = t.replay.ackedAt === null;
45797
46235
  writeTombstone(tombstones, sid, t.replay, now);
45798
46236
  bridgeLogger.logOut({
45799
46237
  spanId: "gc",
45800
46238
  sessionId: sid,
45801
- type: "<tombstone-written>",
46239
+ type: expiredUnacked ? "<result-expired>" : "<tombstone-written>",
45802
46240
  payload: { sessionId: sid, turnId, lastSeq }
45803
46241
  });
45804
46242
  t.replay = null;
46243
+ } else if (t.replay) {
46244
+ retainedBuffers += 1;
46245
+ retainedBytes += t.replay.bytesUsed;
45805
46246
  }
45806
46247
  }
46248
+ if (retainedBuffers > 0) {
46249
+ bridgeLogger.logOut({
46250
+ spanId: "gc",
46251
+ sessionId: null,
46252
+ type: "<buffer-stats>",
46253
+ payload: { retainedBuffers, retainedBytes }
46254
+ });
46255
+ }
45807
46256
  const removed = sweepExpiredTombstones(tombstones, now);
45808
46257
  if (removed > 0) {
45809
46258
  bridgeLogger.logOut({
@@ -45858,6 +46307,7 @@ async function startDirectorBridge() {
45858
46307
  () => server.close(),
45859
46308
  () => dispatchServer.close(),
45860
46309
  () => clearInterval(cleanupTimer),
46310
+ () => suspensionDetector.dispose(),
45861
46311
  () => remoteRelay?.stop(),
45862
46312
  () => tokenRefresher?.stop(),
45863
46313
  () => {
@@ -49826,8 +50276,26 @@ function firstTaskIdToken(text) {
49826
50276
  return m ? m[0] : null;
49827
50277
  }
49828
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
+
49829
50298
  // src/consensus/spec-gate.ts
49830
- var SPEC_HEADS = ["plan-reviewer", "architect", "security-analyst", "scope-analyst"];
49831
50299
  async function runSpecGate(_target, deps, opts) {
49832
50300
  if (!await deps.consensusEnabled()) return { outcome: "SKIPPED" };
49833
50301
  const intent = await deps.readIntent();
@@ -49843,8 +50311,9 @@ async function runSpecGate(_target, deps, opts) {
49843
50311
  ]
49844
50312
  };
49845
50313
  }
50314
+ const baseHeads = resolveHeads(intent.domain);
49846
50315
  const extraHeads = opts?.extraHeads ?? [];
49847
- const heads = [...SPEC_HEADS, ...extraHeads.filter((h) => !SPEC_HEADS.includes(h))];
50316
+ const heads = [...baseHeads, ...extraHeads.filter((h) => !baseHeads.includes(h))];
49848
50317
  const spec = await deps.readSpec();
49849
50318
  let currentHash = spec.contentHash;
49850
50319
  let currentMarkdown = spec.markdownBody;
@@ -50130,6 +50599,25 @@ async function resolveGatedTaskId(client, projectSlug, page) {
50130
50599
  return page.taskId;
50131
50600
  }
50132
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
+
50133
50621
  // src/consensus/code-gate-git.ts
50134
50622
  var import_node_child_process6 = require("node:child_process");
50135
50623
  var DIFF_MAX_BYTES = 2e5;
@@ -50405,12 +50893,15 @@ function buildSpecGateDeps(args) {
50405
50893
  throw new Error(`spec page ${projectSlug}/${specPageId} not found \u2014 cannot gate`);
50406
50894
  }
50407
50895
  const taskId = await resolveGatedTaskId(client, projectSlug, { id: page.id, taskId: page.taskId ?? null });
50408
- if (taskId === null) return { taskId: null, seed: null, title: null };
50896
+ if (taskId === null) {
50897
+ const project = await client.getProject(projectSlug);
50898
+ return { taskId: null, seed: null, title: null, domain: project.defaultDomain };
50899
+ }
50409
50900
  const task = await client.getTask(projectSlug, taskId);
50410
50901
  if (!task) {
50411
50902
  throw new Error(`gated task ${projectSlug}/${taskId} not found for spec ${specPageId} \u2014 cannot gate`);
50412
50903
  }
50413
- return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "" };
50904
+ return { taskId, seed: task.operatorSeedRequest ?? null, title: task.title ?? "", domain: task.domain };
50414
50905
  })();
50415
50906
  }
50416
50907
  return intentPromise;
@@ -50526,9 +51017,10 @@ ${artifact}`,
50526
51017
  }
50527
51018
  const outcome = r["outcome"];
50528
51019
  const rounds2 = r["rounds"];
51020
+ const { domain: domain2 } = await loadIntent();
50529
51021
  try {
50530
51022
  await client.recordSpecConsensusReview(projectSlug, specPageId, {
50531
- contentHash,
51023
+ contentHash: domainScopedReviewHash(contentHash, domain2),
50532
51024
  outcome: String(outcome),
50533
51025
  rounds: typeof rounds2 === "number" ? rounds2 : 0
50534
51026
  });
@@ -50621,10 +51113,43 @@ ${artifact}`,
50621
51113
 
50622
51114
  // src/async-tools/consensus.ts
50623
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
+ }
50624
51144
  async function cachedResult(client, input) {
50625
51145
  const page = await client.getWikiPage(input.projectSlug, input.pageId);
50626
51146
  if (!page) return null;
50627
- 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
+ );
50628
51153
  if (review?.outcome === "CONVERGED") {
50629
51154
  const gatedTaskId = await resolveGatedTaskId(client, input.projectSlug, {
50630
51155
  id: page.id,
@@ -50674,7 +51199,8 @@ async function run(ctx, input) {
50674
51199
  kind: "question",
50675
51200
  taskId: scopeId,
50676
51201
  body: clarification.question,
50677
- origin: "gate"
51202
+ origin: "gate",
51203
+ questionOptions: buildGateQuestionOptions(clarification.category)
50678
51204
  });
50679
51205
  } catch {
50680
51206
  }
@@ -50689,7 +51215,8 @@ async function run(ctx, input) {
50689
51215
  kind: "question",
50690
51216
  planId: scopeId,
50691
51217
  body: clarification.question,
50692
- origin: "gate"
51218
+ origin: "gate",
51219
+ questionOptions: buildGateQuestionOptions(clarification.category)
50693
51220
  });
50694
51221
  } catch {
50695
51222
  }
@@ -51623,7 +52150,7 @@ var mcpToolDescriptions = {
51623
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.",
51624
52151
  [mcpToolNames.updateWikiPage]: "Update a markdown Wiki page.",
51625
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.",
51626
- [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.',
51627
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).`,
51628
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.",
51629
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.',
@@ -52080,7 +52607,12 @@ function createMrrlinTools(options) {
52080
52607
  if (!page || page.folder !== "Specs") {
52081
52608
  return await c.markSpecReady({ projectSlug, pageId, ready });
52082
52609
  }
52083
- 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
+ );
52084
52616
  if (review && review.outcome === "CONVERGED") {
52085
52617
  try {
52086
52618
  const promotion = await c.promoteSpecReadyCas(projectSlug, pageId, {
@@ -0,0 +1,16 @@
1
+ You are a Content Editor. You review a spec/brief for content or marketing-copy work (an article, landing page, email, campaign copy, or content improvement) BEFORE it is executed. You judge the artifact text given; you do not mutate anything. Your job is soundness and fidelity — not personal stylistic taste.
2
+
3
+ When an `OPERATOR INTENT` block is present, it is the operator's OWN words for what the content must do. Judge the spec against it for FIDELITY: does the plan serve the stated audience, goal, and call-to-action, or does it drift?
4
+
5
+ Evaluate against checkable editorial and content standards:
6
+ - **Audience & purpose**: is the target reader and the single primary goal (inform / convert / retain) explicit, and does the planned piece match them? Block copy that has no clear audience or a muddled goal.
7
+ - **Message & structure**: one clear core message, a logical outline (hook → body → CTA), and a specified call-to-action where the goal is conversion. Flag a plan with no through-line or a missing/weak CTA when one is needed.
8
+ - **Factual accuracy & sourcing**: are claims verifiable, and where the piece makes factual/statistical/quote claims, is a source or evidence requirement stated? Block content that would assert facts with no basis, or that risks fabrication.
9
+ - **Brand voice & consistency**: does the plan respect a stated voice/tone and terminology, or contradict it? Flag off-brand tone only when a voice is specified and the plan violates it.
10
+ - **Clarity & readability**: is the intended reading level/length appropriate for the audience and channel? Flag plans that are structurally unreadable (wall-of-text, jargon for a lay audience) — not minor phrasing.
11
+ - **Originality & value**: does the piece add genuine value, or is it thin/derivative filler? Flag plagiarism risk or padding-for-length.
12
+
13
+ Calibrate to MATERIAL issues — block only when the gap would change WHAT gets published or whether it works:
14
+ - DON'T block on: word choices, subjective style, or edits a competent writer resolves in draft.
15
+ - DO block on: missing/contradicted audience or goal, an absent CTA where conversion is the point, a factual-accuracy/sourcing gap, or a plan that would produce off-brand or thin content the operator didn't ask for.
16
+ - Never fabricate facts, sources, or performance data you were not given; reason from the spec text and stated goals. Scale strictness by stated impact.
@@ -0,0 +1,16 @@
1
+ You are an SEO Analyst. You review a spec/brief for search-optimization work (a page, article, metadata set, structured-data change, or technical-SEO task) BEFORE it is executed. You judge the artifact text given; you do not mutate anything. Your job is soundness against real search best practice — not taste.
2
+
3
+ When an `OPERATOR INTENT` block is present, it is the operator's OWN words for what the task must achieve. Judge the spec against it for FIDELITY: does the plan actually serve the stated search goal (target queries, audience, page/URL), or does it drift into unrelated work?
4
+
5
+ Evaluate against established, checkable SEO practice:
6
+ - **Search intent & targeting**: is there a clear primary query/topic and audience, and does the intended content type match the intent (informational vs transactional vs navigational)? Block a spec that optimizes for a keyword whose intent the deliverable won't satisfy.
7
+ - **E-E-A-T & Helpful Content**: does the plan produce genuinely useful, first-hand/authoritative content, or thin/derivative filler that Google's Helpful Content system demotes? Flag keyword-stuffing, doorway-page patterns, or AI-slop-by-volume.
8
+ - **On-page fundamentals**: title tag, meta description, heading hierarchy (single H1, logical H2/H3), internal linking, canonicalization, and URL/slug — are the ones the task depends on specified or clearly derivable?
9
+ - **Structured data**: if rich results are a goal, is the correct schema.org type named and are its required properties covered (valid against Google's requirements, not invented)?
10
+ - **Technical SEO**: indexability (robots/noindex/canonical), Core Web Vitals (LCP/INP/CLS) where the task touches page performance, and crawl/sitemap impact — called out when the task plausibly affects them.
11
+ - **GEO / AI-overview readiness**: where relevant, is the content structured to be quotable/citable by AI answer engines (clear claims, direct answers, extractable facts)?
12
+
13
+ Calibrate to MATERIAL issues — block only when the gap would change WHAT ranks or ships:
14
+ - DON'T block on: cosmetic wording, minor keyword-density opinions, or choices any competent SEO would make the same way.
15
+ - DO block on: intent/deliverable mismatch, a strategy that violates Helpful-Content/spam policy, a missing core on-page or indexability requirement the task depends on, or invented/incorrect schema.
16
+ - Never fabricate metrics, SERP positions, or tool data you were not given; reason from the spec text and stated goals. Scale strictness by stated impact.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/mcp",
3
- "version": "0.3.15",
3
+ "version": "0.3.20",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mrrlin-mcp": "dist/bin.cjs"
@@ -22,12 +22,12 @@
22
22
  "@types/ws": "^8.18.1",
23
23
  "esbuild": "^0.24.0",
24
24
  "tsx": "^4.22.3",
25
+ "@mrrlin/director-e2e": "0.0.0",
25
26
  "@mrrlin/client": "0.0.0",
26
27
  "@mrrlin/codex-client": "0.0.0",
27
- "@mrrlin/director-e2e": "0.0.0",
28
28
  "@mrrlin/tsconfig": "0.0.0",
29
- "@mrrlin/schemas": "0.0.0",
30
- "@mrrlin/wiki": "0.0.0"
29
+ "@mrrlin/wiki": "0.0.0",
30
+ "@mrrlin/schemas": "0.0.0"
31
31
  },
32
32
  "dependencies": {
33
33
  "@iarna/toml": "^2.2.5",